Skip to main content
Databases

Zero-downtime database migrations: schema changes without the 3am rollback

Zero downtime isn't a faster migration — it's a compatibility window where old and new code both work. Expand–migrate–contract, applied to a real column rename.

Thien Nguyen
By Thien Nguyen
Updated July 21, 2026 · 3 min read

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:

PhaseDatabaseApplication
ExpandAdd nullable display_nameStill reads full_name
Dual writeBoth columns existWrite both fields
BackfillFill old rows in bounded batchesRead new, fall back to old
Cut overVerify parityRead only display_name
ContractDrop full_name in a later releaseDelete 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 NULL columns: add nullable, backfill, then add the constraint in a later step — a NOT NULL default on a big table can rewrite it under a lock.
  • New indexes: use the online path (CREATE INDEX CONCURRENTLY in 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 EXCLUSIVE lock 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.

References

Primary documentation and specifications checked when this article was last updated.

DatabasesDeploymentsReliability

Related articles

All articles