Migration Safety
Change a schema or move data without ever creating a minute where the running code and the database disagree.
When to reach for it
Any time old code and new schema will be alive together: renaming a column, changing a type, splitting a table, backfilling a field, retiring a column nobody thinks is used.
What changes
- The change is split into deploys that are each safe on their own — add, write both, backfill, switch reads, stop writing, drop — so none of them needs a maintenance window.
- For every step you can state what the previously deployed version does when it meets the new schema, which is what makes a mid-rollout rollback survivable.
- Backfills run in batches with a known row count, a progress log and a way to stop halfway, instead of one statement that takes a lock and a long lunch.
- Running the backfill twice does not double-apply anything, so a half-finished run can simply be started again.
- The destructive step is separated in time from the deploy that made it unnecessary, leaving a window where undo is still free.
Pairs with
- Rollback PlanWork out how you would undo the change before you make it — and find out early when the honest answer is that you cannot.
- Ship ChecklistThe last pass before a change goes out — what to verify, in the order that catches the most for the least time.
- Demo Then ShipProves the feature works in the actual running app, not only in the tests written for it.
Migration Safety
Old code and new schema will be alive at the same moment. Design for that minute and there is no window where the app is broken.
1. Find everyone who touches the data
Before designing anything, list the readers and the writers: the app, background jobs, other services, admin scripts, analytics exports, and the release currently running that you are about to deploy over. A migration is only safe with respect to a known set of clients.
2. Split it into steps that are each safe alone
The sequence with no broken moment:
1. Add the new column or table, nullable, no default rewrite 2. Write to both old and new, keep reading the old 3. Backfill existing rows in batches 4. Verify old and new agree 5. Read from the new, still writing both 6. Stop writing the old 7. Drop the old, in a later deploy
3. Test each step against the previous release
For every step, answer one question: what does the version in production right now do when it meets this schema? If the answer is "it throws", the step needs a deploy in front of it.
4. Make the backfill boring
It should be re-runnable without doubling anything, batched so it never holds a long lock, resumable from wherever it stopped, and stoppable without leaving a half-state that nobody can describe. Log progress with a count, and know the total row count before you start.
5. Verify with a query, not a feeling
Before the destructive step, run the comparison: rows where the new value is still null, rows where old and new disagree, counts on both sides. The answer is zero, or a number you can explain out loud.
6. Leave the destructive step for later
The drop belongs in its own deploy, days after the code that stopped using the column. Until then, going back costs nothing.
Rules
- Do not ship a schema change and the code that requires it in the same deploy. One of them must tolerate the other's absence.
- Do not rename a column in place. Add, migrate, drop — a rename breaks every client at once, including the one still running.
- Do not run an unbounded update or delete on a large table.
- Do not add a non-nullable column with a default to a big table without knowing what your database version does with it.
- Do not test a migration only against an empty local database. Against production-shaped volume, or you have tested the syntax and nothing else.