Expand-Contract Migrations
how to change a schema without downtime
Developer toolingdeveloper tooling / DevOps / databases
The myth
"To rename a column, I'll just run ALTER TABLE users RENAME email TO email_address, deploy the new code, done."
Actually
The moment that ALTER runs, every old app instance still in the wild is querying a column that no longer exists → 500s until the deploy fully rolls out. Schema and code can't change atomically across a fleet. The fix is to never make a breaking change in one step. Do it in three, and keep old + new working in parallel the whole time. That's Expand-Contract (a.k.a. Parallel Change).
The core rule
At every single moment, the schema must be compatible with BOTH the currently-running code and the code you're about to deploy. Old and new coexist; you only remove the old once nothing reads it.
The 3 phases (rename email → email_address)
- EXPAND — add, never break
- ALTER TABLE users ADD COLUMN email_address text;
- Deploy code that WRITES to both columns, still READS the old one.
- Backfill in batches so you don't lock the table:
UPDATE users SET email_address = email
WHERE email_address IS NULL AND id BETWEEN 1 AND 10000;
(loop the range). Old column stays source of truth.
- MIGRATE — flip the reader
- Deploy code that READS email_address, still WRITES both.
- Now the new column is source of truth. The old one is dead weight but harmless.
- CONTRACT — remove the old
- Deploy code that stops touching email entirely.
- Only THEN: ALTER TABLE users DROP COLUMN email;
Each phase is its own deploy. Never merge two phases into one release — that's exactly the atomicity assumption that bites you.
Where people get it wrong
- Dropping the old column in the same PR that stops writing it — a rolled-back deploy now has no column. Wait a full release cycle.
- Adding a NOT NULL column with no default on a big table → full rewrite + long lock. Add nullable, backfill, then add the constraint.
- Backfilling in one giant UPDATE → holds locks, blocks writes, spikes replication lag. Batch it.
- Forgetting the double-write window: if new code writes only the new column while old code still reads the old one, you silently lose data.
The pattern generalizes
Splitting a table, changing a column type, adding a NOT NULL, moving a foreign key — all follow expand → migrate → contract. It's also how you decouple deploys from migrations: the DB change ships ahead of the code that depends on it, never behind it.
Rule of thumb: additive changes are safe to deploy anytime; destructive changes wait until no running code depends on them.
Your exercise
You need to change a column phone (text) into a normalized phone_e164 (text) with strict validation. Write the ordered steps: which ALTERs, what the code writes/reads in each phase, and where the backfill goes. Bonus: at which exact step is it safe to add a NOT NULL constraint on the new column?