Zero-Downtime Database Migrations: The Expand and Contract Pattern

The dreaded "Maintenance Window: Sunday 02:00 UTC" is often an admission that database migrations cannot coexist with live traffic. For high-volume transactional applications and modern SaaS platforms, shutting down access for two hours to alter a column or split a table is unacceptable.
Zero-downtime schema evolution is an engineering practice governed by one foundational strategy: the Expand and Contract pattern (also known as Parallel Run).
Why Naive Migrations Take Production Down
Executing ALTER TABLE users RENAME COLUMN phone TO mobile_number; on a live production database with concurrent traffic causes catastrophic failure:
- Table Locking: On large tables, the engine may acquire an exclusive metadata lock, queueing all subsequent incoming reads and writes until connection pools saturate and requests time out.
- Version Desynchronization: While container instances update rolling-node-by-node, old application instances try to query
phoneand fail, while new instances expectmobile_numberand crash.
The Four Phases of Expand and Contract
Instead of treating a database migration as a single atomic event, break it down across three separate deployment cycles:
[Phase 1: Expand] --> Add new column; both old & new exist in database
[Phase 2: Dual Write] --> Application writes to both, reads from old or fallback
[Phase 3: Backfill] --> Asynchronous background job copies historic data
[Phase 4: Contract] --> Application switches reading to new; old column dropped
Phase 1: Expand (Additive Schema Migration)
Add the new column without dropping the old one. The new column must be nullable or have a sensible default value so old application code can insert records without knowing the new column exists.
-- Step 1: Add new column safely
ALTER TABLE customer_accounts
ADD COLUMN contact_number VARCHAR(32) DEFAULT NULL;
Phase 2: Dual-Writing Application Logic
Deploy an updated version of your application code that writes to both the old and the new columns simultaneously, but continues reading from the old column.
// Dual-write insertion in application layer
async function saveCustomerAccount(data: CustomerInput) {
return await db.query(
`INSERT INTO customer_accounts (email, phone, contact_number)
VALUES ($1, $2, $2)`,
[data.email, data.phone]
);
}
Phase 3: Background Backfill
Run a throttled background script to migrate historic records where the new column is still NULL. Throttling prevents replication lag spikes and lock contention.
-- Throttled backfill batch query
UPDATE customer_accounts
SET contact_number = phone
WHERE contact_number IS NULL
AND id BETWEEN $start_id AND $end_id;
Phase 4: Contract (Deprecation and Cleanup)
Once 100% of rows are synchronized:
- Update the application code to read exclusively from
contact_numberand stop writing tophone. - Verify production metrics for 48 hours.
- Deploy the final migration to drop the obsolete
phonecolumn.
Migration Tool Comparison
| Mechanism | Migration Type | Downtime Risk | Rollback Complexity | Suitable Workloads |
|---|---|---|---|---|
| Direct DDL Script | Hard rename/drop | High (Locking & crashes) | Impossible without restore | Dev & Staging only |
| Ghost / pt-online-schema-change | Shadow table copy | Near zero | Low (swap back tables) | Large legacy MySQL tables |
| Expand & Contract | Phased application code | Zero | Trivial (flip reader back) | Any distributed architecture |
To learn how to protect databases against operational incidents, review our insights on managed cloud infrastructure and see why backups alone are not a disaster recovery plan.


