data-migration · git:20260828.866dec6 · 2026-08-28 · sha256 2a33c8fae2b6dfae

data-migration git:20260828.866dec6A

Immutable. This exact content is served forever at /api/v1/blob/2a33c8fae2b6dfae.

---
name: data-migration
description: Changes a schema or moves data in production without downtime or loss — column changes, backfills, table splits, and system-to-system moves. Use this whenever the user is writing a migration, altering a table, backfilling data, renaming a column, or moving data between systems. Treat it as required reading before any migration runs against a database with real data. For designing the schema, use data-modeling.
license: MIT
---

# Data migration

Migrations are the most irreversible thing most engineers do routinely. Code can be rolled back;
a dropped column cannot, and a botched backfill can corrupt data in ways no deploy fixes.

Two properties make a migration safe, and both must hold:

- **Backwards compatible:** the currently deployed code works before *and* after it runs
- **Reversible, or backed up:** you can get back, or you have a copy you have tested restoring

If a migration has neither, it needs a maintenance window and a rehearsal.

## 1. Never combine schema change with data change

Separate migrations, separate deploys:

1. Add the new structure
2. Backfill it
3. Switch the code to use it
4. Remove the old structure — later, as its own change

Combining these makes rollback impossible: reverting the code leaves the data half-moved, and
reverting the data loses whatever the new code wrote.

**Done when:** each step is independently deployable and revertible.

## 2. Use expand-then-contract for anything breaking

Renaming a column is not a rename in production. It is:

1. **Expand** — add `new_name`, nullable
2. **Dual-write** — deploy code writing both; reads still use the old
3. **Backfill** — populate `new_name` for existing rows, in batches
4. **Switch reads** — deploy code reading the new column
5. **Stop writing the old**
6. **Contract** — drop `old_name`, well after everything is stable

Slow and boring, and it is the only way to do it without a window. Each step is individually
safe to roll back.

**Done when:** no single step breaks the currently running code.

## 3. Know which operations take locks

The operation that takes an exclusive lock on a large table stops all writes for its duration.
This is the most common cause of migration incidents, and the details are engine-specific —
check yours.

Common traps in Postgres:
- **Adding an index without `CONCURRENTLY`** blocks writes for the build
- **Adding a column with a volatile default** rewrites the table on older versions
- **Changing a column type** usually rewrites
- **Adding a foreign key** takes a lock on both tables while validating — add `NOT VALID`, then
  validate separately
- **A migration waiting behind a long transaction** queues everything behind it, so a "fast"
  migration can still cause an outage

**Done when:** you know the lock each statement takes and how long it holds on production-sized
data.

## 4. Backfill in batches, outside the migration

Never backfill inside the schema migration. It holds a transaction open for the whole run, and a
failure halfway rolls back everything with no progress kept.

Instead, a separate resumable script:
- **Batch:** a few thousand rows at a time, with a pause between
- **Idempotent:** safe to re-run from any point. Track progress by key range, not by offset
- **Throttled:** watch replication lag and back off. Saturating the primary during a backfill is
  its own incident
- **Observable:** log progress so you know whether an eight-hour job is on track

**Done when:** the backfill can be stopped and resumed without losing work or double-applying.

## 5. Rehearse on real data volume

Test against a restored production-sized copy, not a dev database with a hundred rows. A
migration that takes 40ms on 100 rows can take 40 minutes on 40 million, and duration is the
whole question.

Measure the actual runtime, and confirm the rollback works — a down-migration that has never
been run is a hope, not a plan.

**Done when:** you know the real duration and have executed the rollback once.

## 6. Have the plan written before you run it

- **A tested backup**, taken immediately before. Tested means you have restored from it
- **The abort criteria:** what you are watching, and at what value you stop
- **The rollback**, and explicitly what it does *not* undo
- **Who is watching**, and for how long after
- **Timing:** low traffic, and not before a weekend or a holiday

**Done when:** someone else could execute the plan from the document.

## Moving between systems

For system-to-system moves, the same principles plus:

- **Reconcile, do not assume.** Row counts, checksums, and spot-checked records. "The job
  finished" is not verification
- **Dual-write during transition**, with the old system authoritative until reconciliation passes
- **Keep the source read-only and intact** until you are certain. The cheapest insurance available
- **Handle the delta:** records that change *during* the migration are where data is lost

## Report

State what ran, how long it took, what you verified afterwards, and what is now irreversible.
Explicitly list the temporary states left behind — dual-write code, unused old columns — with
owners, or they become permanent.