Database migrations are terrifying. One wrong move and you’ve locked tables, broken queries, or corrupted data. The traditional approach — maintenance window, stop traffic, migrate, restart — works but costs you availability and customer trust.
The expand-contract pattern lets you migrate schemas incrementally, with zero downtime, while your application keeps serving traffic.
The Core Idea
Never make breaking changes in a single step. Instead:
- Expand: Add the new structure alongside the old
- Migrate: Move data and update application code
- Contract: Remove the old structure
Each step is independently deployable and reversible.
Example: Renaming a Column
You want to rename user.username to user.handle. The naive approach:
| |
This breaks instantly. Every query using username fails.
The Expand-Contract Way
Step 1: Expand — Add new column
| |
Now both columns exist with the same data.
Step 2: Dual-write — Update application
| |
Deploy this. Both old and new application versions work.
Step 3: Backfill — Ensure all data migrated
| |
Step 4: Switch reads — Update application
| |
Step 5: Stop dual-write — Update application
| |
Step 6: Contract — Remove old column
| |
Six steps instead of one, but zero downtime and full reversibility at each stage.
Adding a NOT NULL Column
You can’t add a NOT NULL column to a table with existing rows (no default value to use).
Step 1: Add nullable column
| |
Step 2: Backfill with default
| |
For large tables, batch this:
| |
Step 3: Add NOT NULL constraint
| |
Step 4: Add default for new rows
| |
Changing Column Types
Converting price from INTEGER (cents) to DECIMAL (dollars):
Step 1: Add new column
| |
Step 2: Backfill
| |
Step 3: Dual-write in application
| |
Step 4: Switch reads to new column
Step 5: Stop writing to old column
Step 6: Drop old column
| |
Large Table Migrations
On tables with millions of rows, even UPDATE statements can lock for minutes. Use batched updates:
| |
For truly massive tables, use tools like pg_repack, pt-online-schema-change, or gh-ost.
Foreign Key Considerations
Adding a foreign key to an existing column:
| |
Instead:
| |
Index Creation
Creating indexes locks writes on most databases. Use concurrent creation:
| |
Migration Tooling
Use a migration framework that tracks what’s been applied:
Alembic (Python/SQLAlchemy):
| |
Flyway (Java/JVM):
| |
golang-migrate:
| |
The Deployment Dance
Coordinate application deploys with migrations:
- Deploy migration (expand phase)
- Deploy application with dual-write
- Run backfill
- Deploy application reading from new structure
- Deploy application with single-write
- Deploy migration (contract phase)
Each deployment is independent. If step 3 fails, you can roll back to step 2. If step 5 causes issues, roll back to step 4.
When Downtime Is Acceptable
Sometimes a maintenance window is the right choice:
- Schema changes to rarely-used internal tools
- Fundamental restructuring that can’t be incrementalized
- Emergency fixes where speed matters more than availability
But for production systems serving customers? Expand-contract every time.
Database migrations don’t have to be scary. The expand-contract pattern transforms a single terrifying change into a series of small, safe, reversible steps.
It’s more work upfront. But the first time you rename a column at 2pm on a Tuesday with zero customer impact, you’ll understand why it’s worth it.