HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 402 min read

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)

  1. 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.
  1. 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.
  1. 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?