Cloud HostingWeb DevelopmentDevOps

Zero-Downtime Database Migrations: The Expand and Contract Pattern

Database schema migration showing expand phase and contract phase across versions

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:

  1. 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.
  2. Version Desynchronization: While container instances update rolling-node-by-node, old application instances try to query phone and fail, while new instances expect mobile_number and 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:

  1. Update the application code to read exclusively from contact_number and stop writing to phone.
  2. Verify production metrics for 48 hours.
  3. Deploy the final migration to drop the obsolete phone column.

Migration Tool Comparison

MechanismMigration TypeDowntime RiskRollback ComplexitySuitable Workloads
Direct DDL ScriptHard rename/dropHigh (Locking & crashes)Impossible without restoreDev & Staging only
Ghost / pt-online-schema-changeShadow table copyNear zeroLow (swap back tables)Large legacy MySQL tables
Expand & ContractPhased application codeZeroTrivial (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.

Related Articles

Let's grow your business together.

Book a free consultation — no obligation, just honest advice.

Get a Free Quote