BitBootcampBitBootcamp ← all explainers 🔄 Review ⚙ Settings

Drill 101 — The Migration With No Dry Run

Theme: Migrations & Releases · Format: 20-min drill · Difficulty: core

The story

A retail bank's core-ledger team has a tidy piece of housekeeping on the sprint board. The accounts table stores available_balance and posted_balance as NUMERIC(18,4) — exact fixed-point decimal — and an ORM upgrade wants those columns as double precision to match its new default mapping. The ticket reads like a one-liner: ALTER TABLE accounts ALTER COLUMN available_balance TYPE double precision USING available_balance::double precision. It runs in seconds against staging's ten thousand seeded rows, every value round-trips, the diff is clean, and the change is approved.

It ships for the first time ever against production inside the Saturday 02:00 maintenance window. Four hundred million account rows. The ALTER runs, the cast succeeds for every single row, the migration reports zero errors and zero warnings, and the window closes green at 02:40. Monday's dashboards are green too — balances render, payments post, statements generate. Nobody is looking for a problem, because nothing reports one.

Three weeks later, at month-end close, an accountant in Finance opens the reconciliation pack and the general ledger does not tie to the sub-ledger. The gap is small — a few hundred dollars — but it is not zero, and in a bank a few hundred dollars that no journal entry explains is a fire. The difference is smeared across roughly two percent of accounts, each off by a fraction of a cent, in balances that were never touched by a transaction since the migration weekend.

⏸ Stop here and ask the room: the migration cast every row with zero errors and the dashboards stayed green for three weeks. So what exactly got corrupted, and why did nothing catch it?

Run it — facilitator script

Timing for a 20-minute session: 2 min story · 10 min staged questions · 3 min reveal · 5 min transfer.

The cast succeeded on every row — so where did the corruption come from?

Staging ran clean on ten thousand rows. Why didn't that prove anything?

Given it's already in production, how do you even find the two percent that changed?

The reveal

The fix is not "cast more carefully" — there is no careful way to squeeze exact decimal into a binary float. The team restores the affected column from the pre-migration backup, reverts the type back to NUMERIC(18,4), and re-runs the ORM upgrade with an explicit fixed-point mapping instead of double precision. Then they add what the first migration never had: a rehearsal against a restored production snapshot, a transactional run with row-by-row validation that rolls back on any diff, a shadow read comparing old and new for a week after cutover, and a row-count and checksum reconciliation that must read exactly zero before the old column is dropped. The corrupted-row count on the re-run is zero.

Why this happens — the mechanics

The technical root is a type mismatch that the database is happy to perform. Postgres will cast NUMERIC to double precision on request, and for values that binary floating point can represent exactly the round-trip is lossless — which is precisely why it slips through. The migration author sees a clean run and a green window and reasonably concludes it worked; the two percent of rows whose decimal value has no exact binary representation are rounded to the nearest representable double, and rounding is not an error condition. There is no exception, no warning, no failed row — the corruption is the operation succeeding. Money is the one domain where this is unforgivable, and it is exactly why "never store money in a float" is a rule; a migration is just the sneakiest way to break it.

The organizational root is the seam between "the migration ran" and "the migration was correct," and it is bridged only by data. Staging existed, a review happened, a dry run happened — but the dry run was against ten thousand synthetic rows chosen for convenience, not against the production distribution that actually exhibits the failure. A rehearsal on data that cannot express the bug is indistinguishable from a rehearsal that passes, and both feel like diligence. The first time the migration meets production-shaped data is the first time it can possibly fail — and that first time is in production, at 02:00, with four hundred million rows and no old value retained to compare against.

The deepest trap is that a migration mutates the very thing you would use to check it. Once the ALTER commits, the exact pre-migration balances are gone; the dashboards read the new column and show it faithfully, so "correct-looking" and "same as before" have quietly diverged with no artifact that reveals the gap. Detection therefore cannot be a dashboard after the fact — it has to be verification built into the cutover, while both the old and new values still exist side by side. A migration is an experiment; an experiment that overwrites its own control group before measuring has not finished, it has destroyed its evidence.

Diagrams

Problem — how the failure unfolds:

sequenceDiagram
    autonumber
    participant M as Migration
    participant DB as Ledger DB
    participant AP as Banking app
    participant RP as Month-end recon
    Note over M: staging passed on 10k<br/>tidy synthetic rows
    M->>DB: ALTER balance to<br/>double precision
    DB-->>M: 400M rows cast<br/>zero errors, zero warnings
    Note over DB: 2% of cents not<br/>float-representable, rounded
    AP->>DB: read and post balances<br/>nothing looks wrong
    Note over AP,DB: dashboards green<br/>for three weeks
    RP--xDB: GL vs sub-ledger tie-out<br/>fails by a few hundred
    Note over M,RP: found by an accountant<br/>not a monitor

Solution — where the guardrails go:

flowchart TD
    A["Schema migration ready"] --> G1["Rehearse on restored<br/>prod snapshot (full copy)"]
    G1 -.-> AL["Any row diff &gt; 0<br/>blocks the cutover"]
    A --> B["Run inside one<br/>transaction (BEGIN)"]
    B --> G2["In-txn validation of<br/>old vs new, every row"]
    G2 -.-> AL2["Any diff triggers<br/>ROLLBACK, never COMMIT"]
    B --> C["Cut over to new column"]
    C --> G3["Shadow read (compare<br/>old + new on live traffic)"]
    G3 -.-> AL
    C --> G4["Row-count + checksum<br/>reconciliation (sum, hash)"]
    G4 -.-> AL

What good looks like

The lesson

A migration you have never run against production-shaped data is not a plan, it is a bet — and a type cast that succeeds is not the same as a type cast that is correct. Rehearse on a restored snapshot, run inside a transaction that validates and rolls back on any diff, shadow-read the cutover, and reconcile row-count and checksum to exactly zero before you drop the old value.

Named concepts: rehearse on production-shaped data, transactional migration + validation, shadow / dual-read verification, row-count & checksum reconciliation.

Real-world echoes

Transfer

Which migration on your roadmap is scheduled to run against production data it has never actually touched? "It passed in staging" is not an answer unless staging held a restored copy of production — name the migration, and name the data its rehearsal ran on.

Make it a ticket: for the next schema or type migration on the board, make "rehearsed against a restored production snapshot with a row-level diff of zero" a required, checked cutover gate, and add a post-cutover row-count-plus-checksum reconciliation job that must read zero before the old column may be dropped.

Related