There's a specific kind of deploy that feels efficient and is actually a trap, and once you learn to see it you'll spot it in every risky change window: the migration that does three things at once. It renames or retypes a column, backfills the data to match, and ships the code that depends on the new shape — all in a single step, all-or-nothing. It looks tidy. One PR, one deploy, done. It is one of the most reliable ways I know to end up in an incident you can't roll back out of.
Why the all-at-once version is a trap
The problem is that a schema change, a data change, and a code change have completely different rollback characteristics, and welding them together means your rollback is only as good as the worst one.
Code rolls back cleanly — redeploy the previous version, you're done. A schema change often does not roll back cleanly: if you've dropped a column or changed a type, the old data shape may be gone, and "just revert" isn't a thing anymore because the information the old code needs no longer exists in that form. And a backfill over a large table takes time — it's not instant, it runs while traffic is live, and during that run you have some rows in the new shape and some in the old.
Now put them together and deploy as one unit. The instant your new code goes live, it expects the new schema and the backfilled data. But the backfill is still running, so some rows aren't migrated yet — and your new code, which only knows the new shape, hits an old-shaped row and breaks. Or the deploy half-succeeds: schema changed, code didn't, and now the running code expects the old columns that no longer exist. You reach for the rollback and discover there isn't a clean one, because you can't un-drop a column and you can't un-run a half-finished backfill by flipping a switch. You're not rolling back; you're doing emergency surgery on live data at the worst possible moment.
The root mistake is treating "the change" as atomic when it's really three changes with three different risk profiles, forced to share one blast radius.
Expand and contract
The fix is a discipline called expand-contract (or parallel change), and the whole idea is to never have a moment where the code and the schema disagree. You split the one scary migration into a sequence of individually-safe, individually-reversible steps.
Say you're renaming a column from old to new. The trap version does it in one shot. The safe version is:
Expand. Add the new column alongside old. Add nothing that removes or breaks anything — this step is purely additive, which means it's trivially safe and trivially reversible. The schema now supports both shapes at once.
Migrate the writes. Deploy code that writes to both old and new. Every new row is now correct in both places. Still fully reversible — old is still there, still maintained, and if you roll the code back you've lost nothing.
Backfill, in the background, at its own pace. Copy old to new for the existing rows, in batches, decoupled from any deploy. It can take as long as it takes. Nothing depends on it finishing yet, so there's no deadline and no half-migrated code reading half-migrated data. If the backfill hiccups, you fix it and resume; nothing is on fire.
Migrate the reads. Only once new is fully populated and verified, deploy code that reads from new. Now the new column is the source of truth. Still reversible — old is right there, still being written, so rolling back the read is safe.
Contract. Finally, after the new path has run in production long enough that you trust it, stop writing to old and drop it. This is the only destructive step, and by the time you take it, it's boring — everything already runs on new, and old has just been dead weight for a while.
Five steps instead of one. Each one is safe on its own, each one is reversible on its own, and at no point do the code and the schema ever disagree about the shape of the world. That last property is the whole game.
The trade you're actually making
I know the objection, because I've made it: this is so much slower. One PR became five deploys spread over days. That feels like a step backward when you could just change the thing.
Here's how I've learned to think about it. The all-at-once migration is faster in exactly one case — the case where nothing goes wrong. It's optimized for the happy path, and it prices the unhappy path at "unbounded incident with no clean rollback." Expand-contract is slower on the happy path and bounded on the unhappy one: at every single step, your rollback is "redeploy the previous version" or "drop the column I just added," both of which are safe and both of which you can do at 3 a.m. without thinking hard. You're not paying five deploys for nothing. You're buying a guarantee that no single step can put you in an unrecoverable state.
And that's the reframe that made me stop resenting it: a migration you can't roll back isn't a fast migration, it's a bet. Sometimes the bet pays. When it doesn't, the cost isn't "we lost some time," it's "we're doing live data surgery with the site down and no undo." The boring multi-step version isn't bureaucracy. It's the version where every step has an undo button, and in anything touching persistent data, the undo button is worth more than the speed.
The general principle outlives the specific pattern: when a change couples things with different rollback characteristics — schema and data and code — decouple them, and sequence them so that the world is consistent after every individual step, not just after the last one. If your plan only works if every part lands, you don't have a plan. You have a bet with extra steps.
Top comments (0)