Definition
- Expand-and-contract – a migration pattern that breaks a schema change into small, backward-compatible steps so the application stays online throughout.
- Expand – add the new column as nullable with no default; a fast metadata-only operation.
- Dual-write – deploy code that writes to both old and new columns on every insert and update.
- Backfill – populate historical rows in small batched transactions, pausing between batches.
- Switch and constrain – move reads to the new column, add
NOT NULLusing a two-phase approach. - Contract – drop the old column after a bake period; this is the last step because it is irreversible.
The National Bank of India wants to renovate the main vault while it is still serving customers all day. You cannot shut the vault for a week. So you build the new section alongside the old (expand), keep recording every deposit in both the old and new ledgers for a while (dual-write), slowly copy the historical records over in small nightly batches (backfill), then once everything matches, start trusting the new ledger and stop using the old (switch), and only much later tear down the old section (contract).
Understanding the Concepts
Why is a single ALTER TABLE dangerous on a large table?
On many databases, ALTER TABLE big ADD COLUMN x TYPE NOT NULL DEFAULT val historically rewrites every row to store the default and holds an exclusive lock while doing so. On a table with hundreds of millions of rows, this can take hours, during which no reads or writes proceed.
-- Dangerous: locks the 400-million-row accounts table for hours
ALTER TABLE accounts
ADD COLUMN amount_decimal NUMERIC(15,2) NOT NULL DEFAULT 0.00;
-- Table rewrite + exclusive lock = effective outage
Timeline of the naive approach:
T=0 ALTER TABLE begins, acquires exclusive lock
T=0 All reads and writes to accounts are blocked
T+2hr ALTER TABLE finishes rewriting 400M rows
T+2hr Lock released, traffic resumes
Result: 2-hour outage during business hours
Modern PostgreSQL (11+) optimizes adding a column with a constant default to avoid the rewrite, but type changes, non-constant defaults, and constraint validation still lock or scan. You cannot rely on it generally.
What is the expand-and-contract pattern?
Expand-and-contract breaks the change into safe steps where each step is individually fast and backward-compatible. Old and new code and old and new data coexist at every point.
Expand-and-Contract Steps: Step 1: EXPAND ──► Add nullable column (fast, metadata-only) Step 2: DUAL-WRITE ──► App writes to both columns Step 3: BACKFILL ──► Batch-update old rows Step 4: SWITCH ──► Read from new column, add constraints Step 5: CONTRACT ──► Drop old column (after bake period)
How does the expand step work?
Add the new column as nullable with no heavy default. This is a fast metadata-only change on most databases: no rewrite, minimal lock.
-- Step 1: Fast metadata-only change
ALTER TABLE accounts
ADD COLUMN amount_decimal NUMERIC(15,2) NULL;
-- Completes in milliseconds, no table rewrite
How does the dual-write step work?
Deploy application code that writes to both the old and the new column on every insert and update. Now every new or modified row has the new column populated. The app still reads from the old column.
package com.bank.repository;
import java.math.BigDecimal;
import java.sql.*;
public class AccountRepository {
private final Connection connection;
public AccountRepository(Connection connection) {
this.connection = connection;
}
public void updateBalance(String accountId, long amountPaise)
throws SQLException {
// Dual-write: update both old (integer paise) and new (decimal rupees)
BigDecimal amountRupees = BigDecimal.valueOf(amountPaise, 2);
String sql = "UPDATE accounts "
+ "SET amount_paise = ?, amount_decimal = ? "
+ "WHERE account_id = ?";
try (PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setLong(1, amountPaise);
ps.setBigDecimal(2, amountRupees);
ps.setString(3, accountId);
ps.executeUpdate();
}
}
}
How does the backfill step work?
Populate the new column for historical rows in small batches. Batching avoids one giant transaction that locks the table or bloats the WAL.
-- Step 3: Backfill in batches of 5000
DO $$
DECLARE
v_batch_size INT := 5000;
v_updated INT;
BEGIN
LOOP
UPDATE accounts
SET amount_decimal = amount_paise / 100.0
WHERE amount_decimal IS NULL
AND account_id IN (
SELECT account_id FROM accounts
WHERE amount_decimal IS NULL
LIMIT v_batch_size
FOR UPDATE SKIP LOCKED
);
GET DIAGNOSTICS v_updated = ROW_COUNT;
RAISE NOTICE 'Backfilled % rows', v_updated;
IF v_updated = 0 THEN
EXIT;
END IF;
COMMIT; -- release locks between batches
PERFORM pg_sleep(0.5); -- throttle to protect replication
END LOOP;
END;
$$;
How does the switch-and-constrain step work?
Once every row has the new column populated, deploy code that reads from the new column. Then add constraints safely using a two-phase approach.
-- Step 4a: Add a NOT VALID check (instant, no scan)
ALTER TABLE accounts
ADD CONSTRAINT chk_amount_decimal_not_null
CHECK (amount_decimal IS NOT NULL) NOT VALID;
-- Step 4b: Validate the constraint (scans but does not lock exclusively)
ALTER TABLE accounts
VALIDATE CONSTRAINT chk_amount_decimal_not_null;
-- Step 4c: Now safe to set NOT NULL (no scan needed)
ALTER TABLE accounts
ALTER COLUMN amount_decimal SET NOT NULL;
-- Step 4d: Build index concurrently (no lock)
CREATE INDEX CONCURRENTLY idx_amount_decimal
ON accounts (amount_decimal);
How does the contract step work?
After a safe bake period during which the new column is proven in production and no code references the old one, drop the old column.
-- Step 5: Drop the old column (after 2-week bake period)
ALTER TABLE accounts DROP COLUMN amount_paise;
-- Fast metadata operation, irreversible
How do you manage these steps with Flyway or Liquibase?
Each step is a separate versioned migration so the changes are ordered, auditable, and repeatable across environments.
Flyway migration files:
V10__expand_add_amount_decimal.sql (Step 1)
V11__backfill_amount_decimal.sql (Step 3)
V12__constrain_amount_decimal.sql (Step 4)
V13__contract_drop_amount_paise.sql (Step 5)
-- V10__expand_add_amount_decimal.sql
ALTER TABLE accounts
ADD COLUMN amount_decimal NUMERIC(15,2) NULL;
-- V12__constrain_amount_decimal.sql
ALTER TABLE accounts
ADD CONSTRAINT chk_amount_decimal_nn
CHECK (amount_decimal IS NOT NULL) NOT VALID;
ALTER TABLE accounts
VALIDATE CONSTRAINT chk_amount_decimal_nn;
ALTER TABLE accounts
ALTER COLUMN amount_decimal SET NOT NULL;
What should a senior engineer know?
Every step before the contract is reversible, so if something goes wrong you roll back the deploy, not the data. The dual-write window is what makes it safe: both representations are valid simultaneously. The backfill must be throttled to protect replication lag and I/O. Never couple a long-running DDL with a schema-change deploy.
Senior Checklist:
1. Every step before CONTRACT is reversible
2. Dual-write window ensures both old and new code work
3. Backfill is throttled (batch size, sleep between batches)
4. NOT NULL added via NOT VALID + VALIDATE (no exclusive lock)
5. Indexes built CONCURRENTLY
6. Contract only after a bake period with monitoring
7. Each step is a separate Flyway/Liquibase migration
Visual Overview
┌──────────────────────────────────────────────────────┐ │ Expand-and-Contract Migration Pattern │ ├──────────────────────────────────────────────────────┤ │ │ │ Step 1: EXPAND │ │ ┌────────────────────────────────────┐ │ │ │ ALTER TABLE accounts │ │ │ │ ADD COLUMN amount_decimal NULL │ ◄─ fast, │ │ └────────────────────────────────────┘ metadata │ │ │ │ │ Step 2: DUAL-WRITE │ │ ┌────────────────────────────────────┐ │ │ │ App writes to BOTH columns │ │ │ │ Reads still from old column │ │ │ └────────────────────────────────────┘ │ │ │ │ │ Step 3: BACKFILL │ │ ┌────────────────────────────────────┐ │ │ │ UPDATE ... SET new = old / 100 │ │ │ │ In batches of 5000 │ ◄─ throttled │ │ │ COMMIT + sleep between batches │ │ │ └────────────────────────────────────┘ │ │ │ │ │ Step 4: SWITCH + CONSTRAIN │ │ ┌────────────────────────────────────┐ │ │ │ App reads from NEW column │ │ │ │ ADD CHECK NOT VALID │ │ │ │ VALIDATE (no exclusive lock) │ │ │ │ SET NOT NULL │ │ │ │ CREATE INDEX CONCURRENTLY │ │ │ └────────────────────────────────────┘ │ │ │ │ │ Step 5: CONTRACT (after bake period) │ │ ┌────────────────────────────────────┐ │ │ │ DROP COLUMN amount_paise │ ◄─ last, │ │ └────────────────────────────────────┘ irreversible│ └──────────────────────────────────────────────────────┘
Key Takeaway
To change the schema of a large production table without downtime, use the expand-and-contract pattern, breaking the change into small backward-compatible steps so old and new data and code coexist and the application stays online. First expand by adding the new column as nullable with no heavy default, which is a fast metadata-only change. Then dual-write from the application so every new or updated row populates both columns while reads still come from the old one. Then backfill the historical rows in small throttled batches rather than one giant transaction.
Once every row is populated, switch reads to the new column and add constraints safely. For NOT NULL, add a NOT VALID check constraint, validate it without an exclusive lock, then set NOT NULL. Build indexes concurrently. Finally, after a bake period, contract by dropping the old column last, so you always have a rollback path until that point.
At the National Bank of India, Pankaj needed to change the account balance from integer paise to a decimal rupees column on a 400-million-row table. A one-shot ALTER would have frozen the vault during business hours, so the DBA mandated expand-and-contract. Each step was a separate Flyway migration, the backfill was throttled at 5000 rows per batch with a half-second sleep, and the whole process completed over a weekend while the application served customers without interruption.
The senior principle is that every step before the contract is reversible, so you roll back a deploy rather than data. Never couple a long-running DDL with a schema-change deploy; keep locks short and let the batched background job do the heavy lifting.
Remember one sentence: expand with a nullable column, dual-write, backfill in batches, switch reads and constrain safely, then contract by dropping the old column: never one blocking ALTER that rewrites and locks the whole table.
---