SD System Design In Depth
0%
lesson system-designdatabasemigration

Database migration safety

Module 5.1: Production Reliability, SLOs & Resilience Updated Sep 18, 2026
📄 Cheat Sheet
Every engineer should know this.. (Expand-Contract Pattern)
Software Developer Diaries Every engineer should know this.. (Expand-Contract Pattern) Watch on YouTube ↗

A focused, diagram-led walkthrough of the expand-and-contract steps taught in this unit.

0:00 / --:--

The Expand and Contract Pattern

Applying breaking schema changes (renaming a column, splitting tables) directly on a live database is unsafe in a single deployment, because during a rolling deploy old and new application versions run side by side against the same schema. The industry standard is the Expand and Contract (Parallel Run) pattern executed in 4 distinct phases:

  1. Phase 1 (Expand): Add the new column or table alongside the old one. The database now contains both.
  2. Phase 2 (Dual Write): Deploy an application release that reads from the old column, but writes mutations to both the old and new columns simultaneously. A background backfill job migrates historical rows.
  3. Phase 3 (Switch Read): Once backfill and validation pass, deploy an update that switches reads to the new column while still dual-writing, so rolling back remains possible.
  4. Phase 4 (Contract): Stop writing the old column, then drop it after a soak period.

Worked example: renaming users.first_name to given_name

StepChangeReadsWritesSafe to roll back?
1. ExpandALTER TABLE users ADD COLUMN given_name text (nullable, no default)oldoldYes
2. Dual writeApplication release writes both columnsoldold + newYes
3. BackfillBatched job copies old to new where new is nulloldold + newYes
4. VerifyCount mismatches, for example WHERE given_name IS DISTINCT FROM first_nameoldold + newYes
5. Switch readsRelease reads the new column, still dual writesnewold + newYes
6. Stop old writesRelease writes only the new columnnewnewOnly back to step 5
7. ContractDrop first_name after a soak periodnewnewNo (restore from backup)

Backfill arithmetic

A table with 400 million rows, updated in batches of 5,000 rows every 100 ms, processes about 50,000 rows per second and finishes in roughly 2.2 hours. Keep each batch in its own short transaction so replication lag and lock hold times stay low. Throttle when replica lag exceeds a threshold, which is what gh-ost and pt-online-schema-change do. Make the job idempotent (WHERE given_name IS NULL AND id BETWEEN x AND y) so it can be restarted safely.

Engine-specific facts that matter

  • PostgreSQL 11 and later: ADD COLUMN ... DEFAULT with a non-volatile default is a metadata-only change. Before version 11 it rewrote the whole table. Use CREATE INDEX CONCURRENTLY to build indexes without blocking writes.
  • Adding NOT NULL safely in PostgreSQL: add CHECK (col IS NOT NULL) NOT VALID, then VALIDATE CONSTRAINT, which does not block writes. PostgreSQL 12 and later can then set NOT NULL without a full scan.
  • MySQL 8.0: many ADD COLUMN operations support ALGORITHM=INSTANT. For operations that do not, gh-ost and pt-online-schema-change copy into a shadow table and swap it in.
  • Always: set lock_timeout (PostgreSQL) or lock_wait_timeout (MySQL) to a few seconds so a blocked DDL fails fast instead of stalling all traffic behind it.

Failure modes

  • Dual-write divergence: if the two writes are not in the same transaction, a crash between them leaves the columns inconsistent. Put both writes in one transaction, or rely on the backfill and verification steps to repair differences.
  • ORM caches the schema: some ORMs select every column by name. Dropping a column breaks old pods even though they never used it. Remove the column from the model first, then drop it.
  • Replica lag during a large backfill can make read replicas serve stale data, or trigger automatic failover. Throttle on lag.
Check yourself Question 1 of 3
Why keep dual writing recall
From: The Expand and Contract Pattern

In the switch-read phase of expand and contract, the application reads the new column but keeps writing to both columns. Why keep the old writes going at this stage?

Key Takeaways

Never execute breaking schema modifications in a single step on live production databases.
Follow the Expand and Contract pattern across separate application deployment cycles.
Verify dual-write correctness with automated shadow verification before cutting over reads.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue