← Back to the blog

Zero-downtime Go migrations with gomigrate

Most migration guides change a schema. This one changes it while the app is running, using the expand/contract pattern with gomigrate.

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.

Quick answerNever rename or drop a column in one step if the old code is still running. Expand first (add the new column), deploy the new code, contract later (drop the old column). Two deploys, no downtime.

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:

  1. Expand. Add the new column/table alongside the old one. New code writes to both; old code only sees the old one.
  2. Migrate data. Backfill the new column. This can be a migration or a background job; it must be idempotent.
  3. 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 only

Applying 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 app

gomigrate'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 down in 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

Subscribe

More of this, once a month.