SyncTrix logoSyncTrix
All articles
Platform10 min read

Zero-downtime database migrations: the expand-contract discipline

Schema changes that are trivial on a small table lock production for minutes at scale. Here's the expand-contract pattern and the operations that quietly take exclusive locks.

By Marcus Hale
Zero-downtime database migrations: the expand-contract discipline

Renaming a column takes milliseconds on a development database with a thousand rows. On a production table with two hundred million rows, serving traffic, with a connection pool of five hundred, the same statement can take an exclusive lock that queues every query behind it and turns a routine deploy into an outage. The techniques for avoiding this are well understood and consistently skipped.

01The rule that governs everything

During any deploy, two versions of your application run simultaneously - the old one still serving requests and the new one starting up. That window may be seconds or minutes, but it exists, and both versions must work against the same schema.

This single constraint rules out every destructive schema change as a single step. You cannot rename a column, because the old code queries the old name and the new code queries the new one. You cannot drop a column that old code still selects. Every change must be decomposed into steps that are individually backwards compatible.

02Expand, migrate, contract

The pattern that resolves this is three phases separated by deploys. Expand: add the new structure without touching the old, so nothing breaks. Migrate: write to both, backfill existing rows, then switch reads to the new structure. Contract: once nothing references the old structure, remove it.

Renaming a column becomes: add the new column, deploy code writing to both, backfill in batches, deploy code reading from the new column, verify nothing reads the old one, then drop it. Five steps instead of one, spread over days rather than minutes. It is more work, and it is the difference between a routine change and an incident.

  • Expand - additive changes only; old code unaffected.
  • Migrate - dual-write, backfill in batches, then switch reads.
  • Verify - confirm nothing references the old structure before removing it.
  • Contract - drop the old structure in a later, separate deploy.

03The operations that take dangerous locks

Not every DDL statement is equally risky, and knowing which ones block traffic is what lets you move quickly on the safe ones. The specifics vary by database and version - modern Postgres has made several previously blocking operations safe - so verify against your actual version rather than folklore.

OperationRiskSafe approach
Add nullable columnLow - metadata onlySafe to run directly
Add column with defaultLow on modern versionsVerify version; older ones rewrite the table
Add NOT NULL constraintHigh - full table scan under lockAdd as NOT VALID, then VALIDATE separately
Create indexHigh - blocks writesCREATE INDEX CONCURRENTLY
Add foreign keyHigh - locks both tablesAdd NOT VALID, validate in a second step
Change column typeHigh - usually rewrites the tableNew column, dual-write, backfill, swap
Common operations by risk (Postgres)

04Backfills are jobs, not migrations

Updating two hundred million rows in a single statement holds a transaction open for the duration, generates enormous WAL volume, blocks vacuum from reclaiming space, and rolls back entirely if it fails at 95%. Backfills belong outside the migration system, as resumable batch jobs.

Process in batches sized to complete in well under a second each, commit between batches, track progress in a table so the job resumes rather than restarting, and add a deliberate pause between batches to leave I/O headroom for real traffic. Slower in wall-clock time, dramatically safer, and interruptible when something else needs the database.

05Locks queue behind each other

This is the mechanism that turns a slow migration into a full outage, and it surprises people. When your ALTER TABLE waits for a lock held by a long-running query, it does not wait politely. It sits in the lock queue, and every query arriving afterwards - including simple reads that would not have conflicted with the original query - queues behind it.

One blocked DDL statement can therefore stall all traffic to a table within seconds. The mitigation is a short lock_timeout so the migration fails fast rather than queueing, combined with retry logic. Failing and retrying is vastly preferable to holding the queue open.

06Make it routine

Teams that do this reliably have automated the checks rather than relying on review. A linter on migration files flags dangerous operations. CI runs migrations against a production-sized dataset to surface duration. Every migration sets a lock timeout by default via configuration rather than developer memory.

The cultural piece matters as much: normalise multi-step changes spread over several deploys. A team that expects a column rename to take a week will do it safely. A team that expects it to take an afternoon will take the lock.

Topics

zero downtime migrationdatabase schema migrationexpand contract patternpostgres locksbackwards compatible schemadeployment safety

Marcus Hale

Lead Architect · SyncTrix

Writes about the engineering decisions behind production systems - architecture, delivery and the trade-offs that only show up at scale.

Building something like this?

SyncTrix engineers AI, SaaS, platform and cloud systems for enterprises and high-growth teams. Tell us what you're shipping and we'll scope it with you.