Concept · Transactions & indexes
Zero-downtime migrations
Expand the schema, move the code, backfill, then contract — so old and new code both work mid-deploy.
4 minexpand and contractparallel changeonline schema changebackfill
During a rolling deploy, the old and new versions of your code run at the same time against one database. So a schema change has to work with both versions. Renaming a column in one step breaks whichever version expects the other name. The fix is to split every breaking change into backwards-compatible steps. This is called expand and contract, or parallel change.
Example: rename users.name to full_name
- Expand. Add the new column, nullable, with no default that needs a table rewrite. Old code ignores it.
sql ALTER TABLE users ADD COLUMN full_name text; - Dual-write. Deploy code that writes both columns and still reads
name. - Backfill. Copy existing data in small batches so you never hold a long lock or one huge transaction.
sql UPDATE users SET full_name = name WHERE id IN (SELECT id FROM users WHERE full_name IS NULL LIMIT 5000); -- repeat until 0 rows - Switch reads. Deploy code that reads
full_nameand still writes both. - Stop writing the old column. Deploy again.
- Contract. Once no running code mentions it, drop
name. If you need it, addNOT NULLtofull_namehere too, as a constraint addedNOT VALIDand validated separately (PostgreSQL 18 acceptsNOT NULL … NOT VALIDdirectly; on older versions, use aCHECK (full_name IS NOT NULL) NOT VALIDconstraint).
Each step is safe to deploy and safe to roll back on its own. It takes several deploys instead of one, and that is the cost of never going down.
Operations that lock (PostgreSQL)
| Change | Risk | Safer path |
|---|---|---|
CREATE INDEX | Blocks writes for the whole build | CREATE INDEX CONCURRENTLY |
Add NOT NULL / foreign key / CHECK | Scans the table under lock | Add as NOT VALID, then VALIDATE CONSTRAINT |
| Change a column's type | Often rewrites the table | New column + backfill + switch |
Any ALTER waiting behind a long query | Queues all later queries behind it | Set lock_timeout (e.g. '5s') and retry |
Habits
- Keep migrations separate from deploys. Run them as their own step, so a slow migration doesn't hold a deploy hostage and a rollback doesn't try to undo the schema.
- Never drop or rename in the same release that stops using something. Old pods may still be serving traffic.
- Treat data migrations as code. They should be batched, resumable and idempotent.
This is the database half of rolling and canary deploys, and the migrations lesson applies it in Go.