Safe schema migrations and rollback
Code rolls back in seconds; a database does not. A destructive migration that shipped with the deploy makes rollback impossible. Split every schema change into expand/contract across releases so old and new code both work, additive before deploy, destructive after.
The deploy that broke production looked clean. One release shipped the code that stopped using users.legacy_name and, in the same migration, ALTER TABLE users DROP COLUMN legacy_name. It went green. Three hours later a latent bug in an unrelated part of that release forced a rollback — the on-call hit the button that had saved us a dozen times, redeploying the previous artifact. Every request 500’d instantly. The old artifact still ran SELECT ... legacy_name FROM users, and the column was gone. Worse, rolling forward couldn’t restore it either: the data in legacy_name had been dropped, not archived. The rollback button — our whole safety story — did nothing, because the one thing it can’t redeploy is the database. This lesson is about why that happens and the discipline that keeps rollback real: expand/contract.
Why a migration breaks rollback
Rollback works on code because a deploy is just swapping one immutable artifact for the previous one — the old container image still exists, you point traffic back, done in seconds. The database has no previous artifact. It is one mutable, stateful thing, and a migration mutates it in place. Once DROP COLUMN legacy_name has run, “go back” has no meaning: the column and its data are gone, and even if you recreate the column, the rows are empty. The deploy and the schema change are coupled in the release, but only one half of that pair is reversible. So “just roll back the deploy” silently assumes the old code can still read the schema it finds — and a destructive change you shipped with the new code breaks exactly that assumption.
This is sharper than it looks because of how modern releases roll out. A rolling, canary, or blue-green deploy (covered last lesson) runs two versions of your code at the same time against one database. There is a window — sometimes minutes, sometimes the whole canary bake — where vN-1 and vN both serve live traffic. If vN’s migration changed the schema in a way vN-1 can’t tolerate, you’ve broken the still-running old version before you ever press rollback. The schema has to satisfy every version that is live, simultaneously.
Expand/contract: never change a column in one step
The discipline that makes this safe is parallel change, usually called expand/contract. You never mutate a column destructively in a single migration. You split the change across releases so that at every single moment, every version of the code that is running can read and write the schema as it currently is.
-- RELEASE 1, step EXPAND: additive only, runs BEFORE the new code deploys.
-- Old code ignores the new column; new code can start using it. Backward compatible.
ALTER TABLE users ADD COLUMN full_name text; -- nullable, no rewrite
CREATE INDEX CONCURRENTLY idx_users_full_name ON users (full_name);// RELEASE 1, app code: DUAL-WRITE — write both old and new on every mutation,
// and read from the old column still. Backfill existing rows in batches, not one UPDATE.
async function setName(id: string, name: string) {
await db.query(
`UPDATE users SET legacy_name = $2, full_name = $2 WHERE id = $1`,
[id, name],
);
}
// backfill job: copy existing data in bounded batches (e.g. 1k–10k rows per loop)
// UPDATE users SET full_name = legacy_name
// WHERE full_name IS NULL AND id IN (<next batch>); -- repeat until none left-- RELEASE 2: switch READS to full_name (code change only, no schema change).
-- RELEASE 3, step CONTRACT: drop the old column — ONLY after R2 is fully rolled out
-- and proven, when nothing reads legacy_name anymore. This is the one destructive step.
ALTER TABLE users DROP COLUMN legacy_name;A rename is the canonical case, and it is not one RENAME COLUMN — it is add new column + backfill + dual-write + switch reads + drop old, spread across three or more releases. At no point does a running version meet a schema it can’t handle, so the previous artifact always still runs and rollback stays real the whole way through.
Ordering: additive before deploy, destructive after
Expand/contract dictates when each migration runs relative to the deploy, and getting this wrong is what re-creates the original incident. The additive migration (the expand) must run before the new code deploys — the column has to exist the instant new code starts, or new code crashes on a missing column. The destructive migration (the contract) must run after the new code is fully rolled out and you are confident you will not roll back — by then nothing reads the old column. Running the destructive change together with the deploy is precisely what makes rollback impossible, because the moment you roll the deploy back, the old code meets the schema the new code left behind.
▸Why this works
Why can’t you just put the migration and its down rollback in the same release and trust the down script to undo it? Because reversibility on paper is not reversibility of data, nor of a live mixed-version fleet. A down that “restores” a dropped column can recreate the empty column but not the data that was already deleted — that data is gone. And in a rolling or canary deploy, old and new code run simultaneously against one database, so a destructive up breaks the still-running old version immediately, before any down could ever fire. The down script protects you against a failed migration; it does nothing against a successful migration whose schema the previous code can’t read. Only an additive expand/contract sequence — where every version works against the schema at every step — actually keeps rollback safe.
Online DDL: the migration itself can take the table down
You’ve written an expand/contract sequence, the logic is sound — and then your CREATE INDEX on a 50-million-row table locks all writes for three minutes during the morning peak. The sequence was right; the mechanics were not.
Even an additive migration can cause an outage if the DDL locks the table. On Postgres, ADD COLUMN with a non-volatile default is fast and safe, but a plain CREATE INDEX takes a lock that blocks writes for the whole build — use CREATE INDEX CONCURRENTLY so writes keep flowing. Long ALTERs take an ACCESS EXCLUSIVE lock and, behind a queue of active queries, can stall the entire table under load. And the backfill must be batched — one giant UPDATE users SET full_name = legacy_name rewrites every row in a single transaction, holding locks, bloating the table, and running long enough to block autovacuum. Loop it in bounded batches of a few thousand rows instead, committing between them.
Forward-fix vs rollback
Because the database often can’t go back, the senior default for a bad release that carried a schema change is frequently forward-fix — ship a corrected version forward — rather than rollback. But that is only an option you keep open by making every migration backward-compatible in the first place: if every schema change is expand/contract, the previous artifact still runs against the new schema, so rollback also stays available as a genuine choice rather than a button that 500s production. Backward-compatible migrations don’t replace rollback with forward-fix; they make both safe so you can pick per incident.
You must rename a heavily-used column with zero downtime and the ability to roll back the deploy at any point. Which approach?
A release shipped `DROP COLUMN legacy_name` together with the code that stopped reading it. A bug forces a rollback to the previous artifact. Why can the code roll back but the destructive migration can't?
You must rename a column safely across releases with expand/contract. What is the sequence, and what is the ordering of additive vs destructive migrations relative to deploys?
- 01Explain why a destructive migration breaks rollback, especially under a rolling/canary/blue-green deploy, and walk through the expand/contract sequence and migration ordering that keeps rollback safe.
- 02Why isn't a down script enough, what online-DDL hazards bite even additive migrations, and when do you forward-fix instead of rolling back?
Code rolls back in seconds because a deploy just swaps one immutable artifact for the previous one; a database has no previous artifact, so a migration that already ran cannot be auto-undone. A destructive change — DROP COLUMN, RENAME, a type change — shipped together with the deploy is what makes rollback impossible: once the column is gone the old artifact still SELECTs it and 500s, and the data can’t be restored. It is sharper under rolling, canary, or blue-green deploys, where two code versions run at once against one database, so a destructive change breaks the still-running old version before you ever press rollback. The discipline is expand/contract (parallel change): never mutate a column in one step. EXPAND adds the new structure additively (a nullable column, CREATE INDEX CONCURRENTLY) and runs BEFORE the new code deploys; the app then dual-writes old and new and backfills existing rows in bounded batches, not one giant UPDATE; a later release switches reads; and CONTRACT drops the old column only AFTER the new code is fully rolled out and proven. So a rename is 3+ releases, never one RENAME, and ordering is additive-before-deploy, destructive-after-rollout. A down script is not enough — it can recreate an empty column but not the deleted data, and a destructive up breaks the live old version immediately. Online-DDL hazards bite even additive migrations: CREATE INDEX CONCURRENTLY to avoid write locks, watch ACCESS EXCLUSIVE locks on long ALTERs, batch backfills. And because the database often can’t go back, the senior default for a schema-carrying bad release is often forward-fix — but you only keep both rollback and forward-fix safe by making every migration backward-compatible in the first place. Now when you see a migration file that drops a column in the same PR as the code that stopped using it, you’ll know: the rollback button in that release is broken before anyone presses it.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.