Database Migrations
Date: 2026-08-16
Changing schema while the old code is still running. The rule that makes it survivable is that every change must be backwards-compatible at the moment it lands — which turns most “rename a column” tasks into a sequence of four deploys.
What it is
A database migration is a versioned, repeatable change to schema or data, applied as part of deployment.
The hard constraint: during any deploy — and throughout any progressive rollout — old and new application code run simultaneously against one database. So the schema must satisfy both.
Expand and contract
The pattern that makes this work. Renaming email to email_address:
1 EXPAND add email_address, nullable. Deploy.
old code ignores it. Nothing breaks.
2 BACKFILL copy email → email_address in batches.
dual-write both columns from application code.
3 MIGRATE deploy code reading email_address, still writing both.
verify. this is the point of no return, and it's reversible
until you take step 4.
4 CONTRACT stop writing email. Deploy.
later, in a separate release, drop the column.
Four deploys to rename a column. That’s the cost of never having downtime and always being able to roll back.
Step 4 is the one to delay. Dropping the column is irreversible and there’s rarely urgency. Leaving it a fortnight costs nothing and buys a clean rollback path for everything before it.
Safe and unsafe operations
| Operation | Safe during deploy? |
|---|---|
| Add a nullable column | Yes |
| Add a column with a default | Depends — on some engines and versions this rewrites the whole table |
| Add an index | Concurrently, yes. Without, it locks writes |
| Drop a column | Only after nothing references it |
| Rename anything | Never directly. Expand-contract |
| Change a column type | Rarely direct. New column, backfill, swap |
| Add a NOT NULL constraint | Only after backfilling and validating separately |
| Add a foreign key | Add as NOT VALID, then validate separately |
[CHECK: which operations lock and for how long is engine- and version-specific — verify against your database’s documentation before running anything on a large table.]
The table size is what decides risk. Every operation above is instant on 10,000 rows and can lock a production table for minutes on 50 million.
Backfills
Never in the migration itself. A migration that updates ten million rows in one transaction holds locks, bloats the transaction log, and can’t be interrupted.
batch of 1,000 → commit → brief pause → repeat
resumable, interruptible, doesn't hold locks
monitor replication lag and back off if it grows
Run it as a background job with progress recorded, so it survives a restart.
Rules
- Migrations are code. In the repo, in version control, reviewed, applied by the pipeline
- Forward-only in production. “Down” migrations are appealing and rarely correct — reversing a schema change that’s already had data written against it usually loses data. Roll forward with a new migration — Rollback and Forward Fix
- Never destructive in the same release as the code change. Separate them so the code can be reverted independently
- Test against production-scale data. A migration that runs in 40ms locally can lock a table for four minutes against real volume
- Set a lock timeout, so a migration that can’t acquire a lock fails fast instead of queueing every write behind it
- One logical change per migration. Easier to reason about, and easier to skip if one fails
Where it interacts
- Progressive Delivery — during a ramp, both code versions are live, so the schema must serve both. This is the constraint that makes expand-contract mandatory rather than merely good practice
- Feature Flags — a flag can gate which columns the code reads, but it can’t gate the schema. The migration always ships first
- Site Migrations and SEO — a replatform is a data migration with a URL migration attached, and both need mapping
- Backwards Compatibility — the general principle this is a specific case of