SD System Design In Depth
0%
lesson system-designrelational-databasesmigrations

Schema evolution

Module 2.1: Relational Modeling & Query Execution Updated Sep 17, 2026
📄 Cheat Sheet
How do software projects achieve zero downtime database migrations?
Web Dev Cody How do software projects achieve zero downtime database migrations? Watch on YouTube ↗

Concise, concrete walkthrough of the expand, dual-write, backfill and contract sequence for renaming a column while old and new app versions run side by side.

0:00 / --:--

Under the Hood: Zero-Downtime Database Migrations

Renaming or modifying a database column in a distributed system cannot happen instantaneously. At any moment during a zero-downtime deployment, older application code (v1) and newer application code (v2) run concurrently. The industry standard pattern for zero-downtime schema evolution is the Expand and Contract Pattern (Parallel Run).

The Expand and Contract Migration Lifecycle

flowchart TD S1["1. Expand: Add new column 'full_name' as NULLABLE (v1 app still writes 'name')"] --> S2["2. Dual-Write: v2 app deployed; writes to both 'name' and 'full_name'"] S2 --> S3["3. Backfill: Background batch job copies historical data from 'name' to 'full_name'"] S3 --> S4["4. Switch Reads: v2 app switches all read queries to 'full_name'"] S4 --> S5["5. Contract: Deprecate and drop old 'name' column safely"]

Ghost Tables & Tools (GitHub gh-ost / pt-online-schema-change)

For complex schema mutations (like changing column types or primary keys on MySQL), enterprise teams use shadow table tools. pt-online-schema-change keeps the shadow table in sync with triggers; gh-ost is triggerless and tails the binary log. The gh-ost flow:

  1. Create a phantom shadow table with the new schema: _orders_new.
  2. Stream modifications from the primary database binary log (binlog) to continuously sync inserts/updates into the shadow table.
  3. Run a throttled background copier to migrate historical rows without saturating CPU or replication lag.
  4. Cut over with an atomic swap (RENAME TABLE orders TO _orders_old, _orders_new TO orders;) coordinated by gh-ost's lock-and-rename algorithm (a sentry table blocks the RENAME until gh-ost is ready). Queries on the table are blocked briefly, and the window depends on waiting for in-flight queries.

Worked example: renaming name to full_name on a 200M-row table

StepDDL / code changeWhy it is safe
1. ExpandALTER TABLE users ADD COLUMN full_name text;A nullable column with no default is a metadata-only change in Postgres and MySQL 8 (instant ADD COLUMN)
2. Dual-writeDeploy v2: writes both columns, still reads namev1 and v2 can coexist; v1 ignores the new column
3. BackfillUPDATE users SET full_name = name WHERE id BETWEEN ? AND ? AND full_name IS NULL; in batches of 5-10k rowsShort transactions, bounded lock time and WAL, throttled on replica lag
4. VerifySELECT count(*) WHERE full_name IS DISTINCT FROM name returns 0Proves the backfill and dual-writes are correct
5. Switch readsDeploy v3: reads full_name, still dual-writesRollback to v2 remains possible
6. Stop old writesDeploy v4: writes only full_nameNothing reads name anymore
7. ContractALTER TABLE users DROP COLUMN name; with a lock_timeoutMetadata-only in Postgres; space is reclaimed on later rewrites

Backfill math: 200M rows in 10k-row batches is 20,000 batches. At about 50 ms each plus a 50 ms sleep, that is about 33 minutes, while replicas stay within a second of lag. An unbatched single UPDATE would rewrite 200M tuples in one transaction, doubling table size and blocking vacuum for its whole duration.

Which operations are dangerous?

ChangePostgreSQLMySQL 8 InnoDB
Add nullable columnInstantInstant
Add column with constant defaultInstant (PG 11+)Instant (8.0.12+)
Change column type (int to bigint)Full table rewrite under ACCESS EXCLUSIVETable copy; use gh-ost
Add NOT NULLFull scan; use a CHECK constraint NOT VALID, then VALIDATE (PG 12+ can then set NOT NULL without a scan)Table rebuild
Add foreign keyUse NOT VALID, then VALIDATE CONSTRAINTOnline, but checks the whole table
Add indexCREATE INDEX CONCURRENTLYALGORITHM=INPLACE, LOCK=NONE

Failure modes

  • Lock queue pile-up: even an instant ALTER needs a brief ACCESS EXCLUSIVE lock. If it waits behind a 10-minute report query, all traffic queues behind it. Use SET lock_timeout = '2s' and retry.
  • ORM caching: some ORMs cache column lists (for example Rails' ignored_columns exists for this reason), so dropping a column can break running processes.
  • CDC and analytics consumers (Debezium, warehouses) often break on renames even when the app does not.
Check yourself Question 1 of 3
gh-ost vs pt-osc recall
From: Ghost Tables & Tools (GitHub gh-ost / pt-online-schema-change)

Both gh-ost and pt-online-schema-change copy rows into a shadow table and then swap it in. How do they keep the shadow table in sync with ongoing writes?

Key Takeaways

Never execute breaking schema changes in a single deployment; use the Expand and Contract pattern.
Dual-writing during transitions ensures older and newer application pods can run concurrently without errors.
Tools like GitHub gh-ost (binlog streaming) and pt-online-schema-change (triggers) use shadow tables to alter massive tables with zero downtime.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue