THE THREE STAGES IN ONE LINE EACH : Stage A — the machine: convert one record perfectly Stage B — the conveyor belt: run that machine millions of times without losing anything. Stage C — the real run: prepare carefully, then actually do it on real data.

From One Record to Twelve Million: The Complete Guide

From One Record to Twelve Million: The Complete Guide

Three stages every large data-conversion project must go through — the machine, the conveyor belt, and the real run. Explained from zero, by ingoampt.

Converting a pile of messy data into a clean, usable form sounds like one job. It isn’t. It’s three, and they have to happen in order. Most projects discover this the hard way: they build the first stage, assume they’re finished, and then spend weeks painfully learning that stages two and three exist.

This guide walks through all three, in plain English, with one simple example carried the whole way.

The three stages in one line each

Stage A — the machine: convert one record perfectly.
Stage B — the conveyor belt: run that machine millions of times without losing anything.
Stage C — the real run: prepare carefully, then actually do it on real data.

STAGE A the machine one record, perfectly STAGE B the conveyor belt millions, safely STAGE C the real run prepare, then do it You cannot skip ahead. Each stage depends on the one before it.
The three stages. Skipping one doesn’t save time — it just moves the pain later.

The example we’ll use

A warehouse inventory that grew for twenty years across three different systems. Each system named things differently. You want one clean catalogue you can search.

The messy original — one product, split across rows:

field_nametextposition
makerNorthfield Tools1
item_nameCordless Drill 18V2
added20193
barcode50123456789004

The clean result — same product, one tidy row:

namebrandyearcode
Cordless Drill 18VNorthfield Tools20195012345678900

Easy for one product. Now imagine twelve million, across four different product types, each type using slightly different field names.

STAGE A

Building the machine

The first stage produces exactly one thing: a function you can hand a record number, which produces the clean row. No loops. No saving. No progress tracking. Just one record, done properly.

convert_one_record(database, record_number=125)

A.1 — Start with one type

Don’t try to handle everything at once. Pick the most common product type and get it completely working, with tests, before touching anything else.

messy record #125 maker Northfield Tools item_name Cordless Drill 18V the machine clean row name: Cordless Drill 18V brand: Northfield Tools
One record in, one clean row out. That’s the entire job of Stage A.

This first slice is more valuable than it looks. It forces you to answer hard questions early, while they’re still cheap: What if the name is missing? What if there are two names? What if the year isn’t a number? Answer those once, on the simplest type, and every later type inherits the answers.

A.2 — Adding more types, one at a time

Real archives hold several kinds of thing, and each behaves slightly differently.

Original typeClean typeWhat’s different about it
finished productproductthe standard case
spare partproductpoints to a parent product
bundlecontainerholds other products; isn’t one itself
catalogue entryreferencenot a sellable item at all

That last row is the dangerous one. A catalogue entry has a name and a brand, so a careless converter files it as a product. Then a customer searches the catalogue and gets back things they can’t actually buy. Giving it a different label keeps it out of normal searches while still preserving it.

role = product finished products spare parts ↑ normal search looks here role = container bundles — hold products, aren’t products themselves role = reference catalogue entries — nothing you can buy
Separate lanes. A search for products only searches products.
Analogy

Learning to cook, you master boiling an egg before attempting a five-course dinner. One dish at a time, each with its own method, each confirmed working before the next begins.

A.3 — Adding fields, one at a time

Once all the types work with the basics (name, brand, year), you go back and add the finer details. And here the same field name can mean different things depending on the type — which is exactly why they’re added carefully.

Original fieldClean columnDepends on type?
item_namenameno
maker (system 1)brandyes
manufacturer (system 2)brandyes
supplier_name (system 3)brandyes
barcodecode (validated)no

Three different original field names, one clean column. That absorption happens here, once — so nothing downstream ever has to care which system a product came from.

Some fields carry a nice bonus: a barcode has a built-in checksum, a mathematical self-check. So instead of just copying it, you can verify it. An invalid one isn’t discarded — it’s stored and flagged as unverified, so you never treat a broken code as a trustworthy exact match.

The field that has no good answer

Eventually you hit a field like note, used for wildly different things:

Note textActually means
“Discontinued Q3 2021”a status change
“Fragile — handle with care”a handling instruction
“Replaces model 4400”a relationship to another product
The right answer is sometimes “not yet”

You cannot write “every note becomes a status” — it would be wrong two-thirds of the time. So notes get their own table, preserving exact text and order, without pretending to understand more than the source actually says. Some fields stay deliberately un-interpreted, and that is a real decision, not a gap.

A.4 — The three unbreakable rules

Rule 1 — all or nothing, per record

Writing one clean product touches several tables: the main row, its categories, its identifiers. If the third write fails, the first two must not survive. Databases handle this with a savepoint — a marker you can rewind to.

Record #125 fails halfway name written ✓ brand written ✓ code FAILS ✗ rewind → all three gone — no half-product remains
Either the whole product is written, or none of it is.

Rule 2 — never guess a missing value

If the original has no year, the clean row’s year stays NULL — meaning “we don’t know.”

You writeIt claimsCorrect for a missing year?
NULLwe don’t knowYes — honest
“”the year is blank textNo — invented
0the year is zeroNo — invented
“unknown”the year is that wordNo — pollutes the column
Why guessing is worse than it looks

A guessed value is indistinguishable from a real one. Six months later nobody can tell which years were genuine and which were filled in. The data quietly stops being trustworthy, and you cannot tell when it happened.

Rule 3 — preserve exact text and exact order

A code stored as 5012345678900 is not “helpfully” reformatted into 501-2345-678900. Contributor 1 stays contributor 1. If you want a cleaned-up version for matching later, you create it separately and label it clearly as derived — never overwriting the original.

A.5 — How each addition gets tested

Every new type and every new field gets the same treatment: write a test that describes the correct result, watch it fail, then write the code that makes it pass.

REDtest fails first GREENmake it pass REFACTORtidy up
The loop repeats for every type and every field added.
Why the test must fail first

If a test passes before you write the feature, it isn’t testing what you think. Watching it fail proves the test can actually detect a missing feature — which is what makes the later pass meaningful.

Each addition gets both a positive test (the good case works) and a negative test (the bad case is refused). And crucially, every earlier test runs again. That’s how you find out that adding notes accidentally broke barcodes — instantly, instead of eleven million rows later.

STAGE B

Building the conveyor belt

The machine works. Now run it twelve million times. This is a genuinely different problem, and treating it as “just add a loop” is where projects lose weeks.

B.1 — The loop that looks fine and isn’t

for record_number in all_records:
    convert_one_record(database, record_number)

Reasonable-looking. Also a disaster at scale.

records 1 → 4,000,000 converted ✗ stops here Where did it stop? Was anything saved? Why did it fail? Can we continue? No answers. Start over from record 1.
Three hours of work, no recovery, no diagnosis.

B.2 — The five problems only scale creates

None of these exist for one record. All five appear at millions.

Problem 1 — when to save

Saving is slow. Save after every record and the job crawls; save only at the end and a crash loses everything.

Save every…SpeedLost in a crash
1 recordvery slowalmost nothing
1,000 recordsfastat most 999 records of work
only at the endfastesteverything

Groups of around a thousand are a common sweet spot: redoing a thousand records costs seconds, redoing three hours does not.

Problem 2 — knowing where you stopped

The trap almost everyone falls into

Writing progress to a separate file feels obvious: save the data, then write “reached record 4,000,000” to a log. But if the crash lands between those two actions, the file and the database now disagree — and nothing tells you which one is lying.

Separate — can lie ✗ save 1,000 records ✓ ✗ crash here write progress file — never happened database and file now disagree Together — cannot lie ✓ save 1,000 records + progress marker as ONE action both land, or neither does
The progress marker rides inside the same save as the data.

Problem 3 — running twice must be safe

You will run this more than once — after a crash, after a fix, after a false start. Running again must never produce duplicates. The property has a name: idempotency.

for each record:
    already converted?  →  skip it
    not converted yet?  →  convert it
A free bonus

This also answers “where do I resume?” without being told. Start from the beginning again; finished records get skipped in bulk, and it naturally picks up where it stopped.

Problem 4 — what to do when a record fails

ApproachBehaviourGood when
Stop immediatelyfirst failure halts the runearly — a failure means your rules have a gap and you want to know now
Skip and continuelog it, keep goinglater, once you trust the rules and expect only rare oddities

Stopping first is usually right. Discovering a rule gap at record 5,000 is far better than discovering eleven million wrong rows afterwards.

Problem 5 — proving it actually finished

“The program ended without an error” is not proof. Counting is.

selected:   12,796,119
converted:  12,796,119   ✓ match
failed:              0   ✓
Count per category, not just the total

If 100 products were wrongly filed as bundles, products are 100 short and bundles 100 over — and the grand total is unchanged. It looks perfect. Only a per-category count exposes it.

Grand total — looks fine ✓ start 8,400 = end 8,400 Per category — exposes it ✗ products: 8,000 → 7,900 (−100) bundles: 400 → 500 (+100)
The total balances. The categories don’t. Always check both.

B.3 — Three tables that solve all five

TableHoldsRows
runsone receipt per attempt: start time, settings, how it endeda handful
checkpoints“safely saved up to record X”one per group
failureswhich record failed, at what stage, and whyhopefully zero

Make them immutable: a retry doesn’t edit the failed run, it creates a new one pointing back at it.

Run A — started 14:25 — FAILED at record 4,200,000
Run B — started 16:00 — retry of Run A — COMPLETED ✓

Both survive forever. You can always reconstruct what happened, instead of finding one row overwritten three times that tells you nothing.

B.4 — Testing a three-hour job in one second

You can’t test a twelve-million-record run by running it. You test the behaviour on tiny throwaway databases with a handful of fake records.

TestWhat it proves
convert 5 recordsthe happy path works
run it twiceno duplicates — idempotency holds
break record 3 on purposerecords 1–2 survive; record 3 leaves nothing behind
kill it mid-run, restartresumes correctly, doesn’t redo finished work
feed it an unknown typerefuses loudly instead of guessing
check final countsthe numbers reconcile
A neat trick for forcing failures

To make a failure happen at exactly the right moment, add a temporary database rule that rejects one specific write. The converter hits it, fails realistically, and you get to watch whether rollback actually works — without waiting for a real crash to happen by luck.

Tests three and four are the ones people skip, and they’re the most valuable. A recovery path you’ve never exercised is a recovery path you cannot trust.

STAGE C

The real run

Everything works on fake data. Now comes the part that actually touches real data — and this is where a different kind of care applies. Up to now, mistakes cost you a rerun. From here, mistakes can cost you the data itself.

C.1 — The readiness checklist

Before pressing start, every one of these gets a verified answer. Not “probably” — verified.

CheckWhy it mattersHow to verify it
Backup existsthe only real protection against everything else going wrongcompare file sizes and checksums, not just “I think I made one”
Disk spacea run that fills the disk halfway through fails badlyestimate the growth, check free space, leave generous margin
Expected runtimeso you know whether “still going after 2 hours” is normal or stuckmeasure a small sample, multiply
Exact commandimprovising a command at midnight is how accidents happenwrite it down beforehand, character for character
Interruption planlaptops sleep, power flickersknow the resume command before you need it
Post-run validationproof, not hopedecide the exact counts you’ll check afterwards
The backup check people skip

“I made a backup” is not a verified backup. Check the file size matches, check a checksum, and — ideally — open the copy and count some rows. A backup you’ve never verified is a backup you’re guessing about.

C.2 — Adding tables to a live database

Those three tracking tables from Stage B don’t exist in the real database yet. Adding them is called a migration, and it deserves its own small ceremony.

1. check the database is the expected version
2. check the new tables don't already exist
3. add the tables + indexes
4. update the version number
5. verify everything landed
   — all in ONE transaction: works completely, or changes nothing
Success 3 new tables + indexes version 1 → 2 Any failure nothing added version stays 1 No middle state is possible.
A migration either fully succeeds or leaves the database exactly as it was.
The gotcha that catches people

If your project has a validator that checks “the database should have exactly N tables,” adding three tables makes it reject your database as broken. Update the validator in the same change, or you’ll get a very confusing failure right after a successful migration.

C.3 — The rehearsal

Before the real run, do a smaller one. Take a copy — or a bounded slice — and run the real command against it.

Rehearsal tells youWhy you want to know beforehand
actual speed per thousand recordsturns “some hours” into a real estimate
how much the database growsconfirms your disk-space margin is real
whether resume works on real datafake data can hide real-world surprises
whether any real records are oddfar better to find this now than at hour two

C.4 — The run itself

Practical things that matter more than they sound:

  • Stop the computer sleeping. A laptop that suspends mid-run is the most common interruption of all.
  • Log to a file, not just the screen. Terminal windows get closed, scrollback gets lost.
  • Watch the first few thousand. If something is wrong, it’s usually wrong immediately. Don’t walk away in the first minutes.
  • Then genuinely walk away. Watching a progress counter for three hours helps nobody.
If it stops, don’t panic

This is exactly what all of Stage B was for. The checkpoint tells you where it got to, the failure table tells you why it stopped, and resuming picks up from the last safe group. An interruption is an inconvenience, not a catastrophe — because you built for it.

C.5 — Proving it worked

The run ending is not the finish line. The verification is.

total converted        = total selected        ✓
per-category counts    = expected per category ✓
failures               = 0 (or all reviewed)   ✓
database integrity     = ok                    ✓
original data          = unchanged             ✓

That last line deserves emphasis. The whole point of a raw layer is that it stays untouched. Confirming it after a big write is cheap, and it’s the difference between believing your safety net exists and knowing it does.

Only now are you finished

A run that “completed” but whose numbers don’t reconcile has not succeeded — it has failed quietly, which is worse. Counting is the only proof.

Lessons you can reuse anywhere

  1. “Works on one” and “works on millions” are different problems. Budget time for both.
  2. Build the machine before the conveyor belt. Don’t wrap batching around unproven logic.
  3. Add one type and one field at a time. Small changes make failures traceable.
  4. Never guess a missing value. Empty is honest; invented is permanent.
  5. Preserve exact text and order. Derived versions are separate and labelled.
  6. Save in groups, not every record and not only at the end.
  7. Write progress inside the same save as the data. Separate files drift and lie.
  8. Make re-running safe. Check-then-skip beats remembering.
  9. Keep run history immutable. New attempt, new row, linked to the old.
  10. Test the crash, not just the success. Deliberately, on a throwaway copy.
  11. Verify the backup before the big write, not after.
  12. Rehearse on a slice before the full run.
  13. Prove completion by counting, per category. “No error” is not proof.

FAQ

Why can’t I just write the loop and be done?
You can — for a few thousand rows you can simply rerun it. The moment a run takes long enough that redoing it hurts, every point in Stage B starts earning its keep.
Why not save after every record to be safest?
Saving is expensive. Doing it twelve million times can turn a two-hour job into a multi-day one. Groups of around a thousand give you most of the safety at a fraction of the cost.
What does idempotency mean in practice?
Running the job twice gives the same result as running it once. Concretely: check whether a record is already converted and skip it if so.
Should one bad record stop everything?
Early on, yes — it usually means your rules have a gap, and you want to find that at record 5,000. Switch to skip-and-log once you trust the rules.
How do I test something that takes three hours?
You don’t run the real thing. Run the same code on a handful of fake records in a throwaway database, and deliberately break it to check recovery works.
Why keep failed run records instead of cleaning them up?
The history is the diagnosis. If retries overwrite the failed attempt, you lose the only evidence of what went wrong.
Is a rehearsal really necessary if all tests pass?
Yes. Tests use fake data you invented; real data contains things you didn’t imagine. A rehearsal on real records is the cheapest way to meet those surprises early.
What if the run gets interrupted anyway?
Then Stage B does its job: check the checkpoint, check the failure table, resume from the last safe group. That is precisely the scenario it was built for.

This article was written by ingoampt.

© ingoampt — practical guides to building data systems that survive contact with reality.

Leave a reply

Your email address will not be published. Required fields are marked *