A migration that works fine in staging will take down production if it locks a hot table during a deploy. Zero-downtime migrations require a different mental model: you're not changing the schema in one step, you're evolving it across multiple deploys so the old and new code can run simultaneously. This post walks through the expand/contract pattern and how to apply it with gomigrate.
What breaks during migrations
Four migration operations are safe under concurrent traffic. Everything else requires care:
- Safe: adding a nullable column, adding an index concurrently (
CREATE INDEX CONCURRENTLY), adding a table, adding a constraint as NOT VALID. - Dangerous:dropping a column (old app still reads it), renaming a column (old app can't find it), adding a NOT NULL constraint without a default (locks the table for a full scan), dropping a table the old app uses.
The expand/contract pattern
Every breaking schema change becomes three phases:
- Expand. Add the new column/table alongside the old one. New code writes to both; old code only sees the old one.
- Migrate data. Backfill the new column. This can be a migration or a background job; it must be idempotent.
- Contract. Once all instances are running the new code and the old column is no longer read, drop it.
Example: renaming full_name to display_name:
-- Phase 1 expand: 0010_add_display_name.sql
ALTER TABLE users ADD COLUMN display_name TEXT;
UPDATE users SET display_name = full_name WHERE display_name IS NULL;
-- deploy new code: writes display_name, reads display_name with fallback to full_name
-- Phase 3 contract: 0012_drop_full_name.sql
ALTER TABLE users DROP COLUMN full_name;
-- deploy final code: reads display_name onlyApplying it with gomigrate
gomigrate's sequential file numbering maps directly to the expand/contract phases. Each phase is a separate migration file; each file is deployed with the code that depends on it.
migrations/
0010_add_display_name.sql ← deploy with v2 app
0011_backfill_display_name.sql
0012_drop_full_name.sql ← deploy with v3 appgomigrate's status command shows which migrations have run. In a deploy pipeline, run gomigrate status before the deploy to confirm the database is in the expected state.
Rollback strategy
Most migration tools support downmigrations. In practice, rolling back a schema change in production is risky — you're reversing a change that live data may depend on. The safer strategy:
- Roll back the application code, not the schema. The old app can run on the new schema if you used expand/contract correctly.
- Only run
gomigrate downin development and staging. - For genuinely broken migrations in production, write a forward fix migration and deploy it.
Safe migration checklist
- Adding a column: nullable, or has a constant
DEFAULT - New index: use
CREATE INDEX CONCURRENTLY - New constraint: add as
NOT VALID, validate separately - Rename: use expand/contract across two deploys
- Drop: only after all app instances no longer reference it
- Backfill: run in batches on large tables, not a single UPDATE
The full gomigrate documentation, including down migrations and the status command, lives at github.com/taqnihub/gomigrate. For the basics, see the gomigrate getting started post.
End of article · Thanks for reading