// Database
Zero-Downtime Database Migrations with Expand and Contract
Rename columns and change schemas on live tables without an outage, using the expand and contract pattern and lock-aware DDL.
Md Nayeem HossainFeb 24, 20253 min read// Database
Rename columns and change schemas on live tables without an outage, using the expand and contract pattern and lock-aware DDL.
Md Nayeem HossainFeb 24, 20253 min readThe scariest deploys I have run were never about application code. They were schema changes: renaming a column on a table with forty million rows while traffic kept flowing. Application code can be rolled back in seconds. A dropped column cannot.
The technique that makes these changes boring is called expand and contract (also known as parallel change). This post walks through it with real examples.
During a rolling deploy, old and new versions of your application run at the same time against the same database. If a migration renames users.name to users.full_name, every old instance still querying name starts failing the moment the migration runs. Rolling back the code does not help, because the column is already gone.
The rule that follows: every schema change must be compatible with both the current and the next version of the code.
Expand and contract splits one breaking change into several safe ones:
Each phase is its own deploy, and each one can be rolled back independently.
Deploy 1 — expand. Add the new column as nullable:
1ALTER TABLE users ADD COLUMN full_name text;Adding a nullable column without a default is a metadata-only change in PostgreSQL. It is instant even on huge tables.
Deploy 2 — dual write. The application writes both columns on every insert and update, but still reads the old one:
1await repo.update(id, { name: input.name, fullName: input.name });Backfill. Copy existing data in small batches to avoid long locks and replication lag:
1UPDATE users2SET full_name = name3WHERE id IN (4 SELECT id FROM users5 WHERE full_name IS NULL6 LIMIT 50007);Run it in a loop with a short pause until it updates zero rows.
Deploy 3 — switch reads. Read full_name, keep writing both. Watch dashboards for a day.
Deploy 4 — contract. Stop writing name, then drop it:
1ALTER TABLE users DROP COLUMN name;Four deploys sounds like a lot for a rename. It is. That is the price of never having a broken window, and for core tables it is cheap insurance.
Some DDL statements take locks that block reads or writes for the duration of the operation. On a busy table, that is an outage.
CREATE INDEX blocks writes. Use CREATE INDEX CONCURRENTLY, outside a transaction.NOT VALID, then VALIDATE CONSTRAINT separately, which uses a weaker lock.1ALTER TABLE orders2 ADD CONSTRAINT orders_customer_fk FOREIGN KEY (customer_id)3 REFERENCES customers (id) NOT VALID;4 5ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_fk;Even "safe" statements need a brief exclusive lock. If a long-running query holds a conflicting lock, your migration waits — and every query behind it queues up too. One stuck migration can freeze the whole table.
Always set a timeout so the migration fails fast instead:
1SET lock_timeout = '3s';2SET statement_timeout = '15min';A failed migration you can retry is far better than a queue of blocked connections.
Zero-downtime migrations are not about clever SQL. They are about discipline: small reversible steps, compatibility between versions, and respect for locks. Once the team adopts expand and contract as the default, schema changes stop being events and become routine.