Zero-downtime migration is not a migration that runs fast. It's not a maintenance window at 3am, and it's not a clever ALTER TABLE that finishes in milliseconds. It's a schema that speaks two application versions at once for a while. That reframing is the whole game: during any rolling deploy, old pods and new pods hit the same database simultaneously, so the only migrations that are actually safe are the ones where both code versions keep working against the current schema. Speed is irrelevant if the new schema breaks the pods that haven't been replaced yet.
The pattern that gives you that overlap is expand–migrate–contract, also called parallel change: make the schema strictly additive first, move data and traffic onto the new shape while the old shape still functions, verify, and only later — in a separate deploy — remove the old shape. It's deliberately boring, because every step in the middle is independently rollback-able.
Rename full_name to display_name without breaking old pods
The naive version — ALTER TABLE users RENAME COLUMN full_name TO display_name in the same deploy as the code change — breaks every old pod the instant it runs, because they're still selecting full_name from a column that no longer exists. Here's the same rename as parallel change:
| Phase | Database | Application |
|---|---|---|
| Expand | Add nullable display_name | Still reads full_name |
| Dual write | Both columns exist | Write both fields |
| Backfill | Fill old rows in bounded batches | Read new, fall back to old |
| Cut over | Verify parity | Read only display_name |
| Contract | Drop full_name in a later release | Delete the compatibility code |
The backfill is the step that turns a "harmless rename" into an incident, so it's batched, not one giant UPDATE:
UPDATE users
SET display_name = full_name
WHERE id > :last_id AND id <= :next_id
AND display_name IS NULL;Bounded batches keep the operation observable (you can watch progress), restartable (a failure resumes from :last_id), and gentle on replicas. One unbounded UPDATE users SET … takes a long write lock, floods replication, and lags every read replica behind — the exact thing that pages someone.
Changes that need extra suspicion
- New
NOT NULLcolumns: add nullable, backfill, then add the constraint in a later step — aNOT NULLdefault on a big table can rewrite it under a lock. - New indexes: use the online path (
CREATE INDEX CONCURRENTLYin Postgres) and never assume DDL is non-blocking by default. - Type changes: add a new column and convert gradually when the cast can fail or shift semantics, rather than mutating in place.
- Destructive drops: always a separate deploy, run only after dashboards confirm no old-code traffic still references the thing.
"It ran fine in staging" answers the wrong question. Staging has a tiny table and no concurrent load; the thing that hurts is a long lock or replica lag on a hot production table. Test against production-like volume and inspect the lock behavior your specific database exhibits — an
ACCESS EXCLUSIVElock on a busy table is where "zero downtime" quietly becomes "brief outage."
The payoff isn't just avoiding the outage — it's what happens when a step goes wrong. Because every phase is additive and reversible, a bad deploy rolls back to the previous phase instead of to a database snapshot. You trade one terrifying irreversible cutover for four dull, undoable steps, and that trade is the entire point: zero downtime is a compatibility problem you solve in the deploy plan, long before you write any SQL.
Cover photo by panumas nikhomkhai on Pexels.
