Skip to content
intermediate Phase 14 · Databases, SQL, and Data Access

Schema Migrations

Version schema changes with ordered SQL files applied across environments.

35m
0 problems
Topic Progress 0%

It Works on Your Database

The bug report says production is missing an index that staging has had for a month. Investigation shows staging's table was altered by hand during a demo, production's was altered from a three-month-old script, and two developers' local databases have a column nobody remembers adding. The schema is not a thing the team has — four environments each have their own, and nobody can say which is current.

The fix is treating schema the way code is treated: versioned, ordered, and applied from one place. A migration is a file containing one change; a migration history is a sequence; each database records which migrations it has applied, and "current schema" means "the result of running migrations 1 through N in order."

-- migrations/000012_add_orders_customer_index.sql
-- +up
CREATE INDEX CONCURRENTLY idx_orders_customer_created
    ON orders (customer_id, created_at DESC);

-- +down
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_customer_created;

The numbering is the contract: monotonically increasing, no gaps reused, applied in order. Tools like golang-migrate or goose keep a bookkeeping table — conventionally schema_migrations — holding the highest applied version, so "which migrations does this database need" is arithmetic, not archaeology:

applied: 1..11        files: 000001..000013     pending: 12, 13

What this buys is measurable. Fresh environment: run all pending, done — identical to production's history. Deploy: apply pending before (or as part of) releasing code. New developer: migrate up and the schema exists. The manual ALTER TABLE in a runbook — the thing that caused the drift — stops being a valid way to change anything, because the next migrate up either reproduces it deliberately or the checklist catches that it is missing.

Three disciplines separate migrations from SQL notes. One change per file, immutable once applied — editing 000004 after it ran in production means two databases disagree while both claim version 4; the mistake needs a new 000014 that undoes and redoes. Migration SQL lives in the repository next to the code that depends on it, so a schema change and the query that requires it arrive in the same pull request (repository boundary). Every migration ships with its inverse for local and CI use — production rarely rolls back (the forward-fix habit), but developers revert constantly and need migrate down to work.

The mechanics of running them — where, when, and how many at once — are where teams still break production, and they deserve their own attention.

Five Replicas, One Migration, No Volunteers

The service reads migrate.Up() at startup. The deploy rolls out five replicas; all five see pending migrations and race: two run 000012, one crashes halfway through 000013, and the pod that applied 000012 first then dies while another waits on a lock. The schema may end correct, or the deploy may end in CrashLoopBackOff — the design gives both outcomes.

Migration application needs an authority: exactly one actor, at a defined point. The two established shapes:

Option A (preferred):  CI/CD runs `migrate up` as a deploy step, BEFORE new code serves traffic
Option B:              app runs migrations at startup, guarded by an advisory lock

Option A keeps migration failures where they belong — the pipeline — and never lets a half-migrated schema meet new code. It also makes rollback a deploy decision, not a mystery. Option B exists for platforms where controlling the pipeline is impractical; it must then be correct:

func ensureSchema(ctx context.Context, db *sql.DB) error {
	// Postgres advisory lock: one process migrates, others wait then see "done"
	conn, err := db.Conn(ctx)
	if err != nil {
		return fmt.Errorf("acquire conn: %w", err)
	}
	defer conn.Close() // return the session; the lock dies with it

	var got bool
	err = conn.QueryRowContext(ctx, `SELECT pg_try_advisory_lock($1)`, migrateLockID).Scan(&got)
	if err != nil {
		return fmt.Errorf("lock: %w", err)
	}
	if !got {
		// someone else is migrating; wait for them, then verify applied == expected
		return waitForCurrent(ctx, conn)
	}
	defer conn.ExecContext(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock($1)`, migrateLockID)

	return runPending(ctx, conn) // apply files > schema_migrations version, record each
}

The db.Conn detail matters: advisory locks are session scoped, so the lock and the migration must run on the same connection — checking out a random connection for the lock and running migrations through the pool would hold nothing. Note also what the lock does not give you: it prevents concurrent application, not stale application. New code arriving before old schema is a sequencing problem, and only Option A's ordering solves it fully.

Where the files come from: embed them in the binary with //go:embed migrations/*.sql, so the artifact that ships is self-describing and a migration can never be missing from a release. The runner reads the FS, compares versions against schema_migrations, applies pending ones in order, and stops on the first failure — never "best effort" partial application. Record each applied file in the same transaction as its DDL where the statement allows it, so "applied" and "effect" cannot disagree.

Whatever runner you choose — hand-rolled, goose, golang-migrate — the properties to verify are the same: single authority, ordered application, recorded versions, failure stops the line. The last open question is what the migration files should contain when the change itself is dangerous at scale.

Expand, Migrate, Contract: The Deploy-Safe Change

The migration reads innocently: ALTER TABLE orders ADD COLUMN customer_id BIGINT NOT NULL. On a table with 200 million rows, this rewrites (or at minimum exclusively locks) the table for the duration — minutes during which every order write blocks. The deploy window assumed seconds. Traffic backs up behind the lock, health checks fail, and the rollback button cannot help: the ALTER is still running.

The pattern that survives rolling deploys splits any risky change into three phases that each leave the system runnable:

EXPAND     add nullable column / new table
           old code runs untouched; new code writes both (dual-write)

MIGRATE    backfill old rows in small batches, during off-peak
           UPDATE orders SET customer_id = ... WHERE id BETWEEN $1 AND $2
           (loop with sleeps; each statement is short, locks are brief)

CONTRACT   add NOT NULL / constraint, switch reads fully,
           stop dual-writing, drop the old column — in a LATER deploy

The property that matters: at every point, old and new code both work. A rolling deploy runs them side by side for minutes, so no migration may assume the fleet switched atomically. Adding a column is expand-only and safe; adding it NOT NULL with an instant default (Postgres 11+ handles fixed defaults without rewrite) is often safe too — the dangerous moves are backfills, rewrites, renames, and constraint validation on live data, each of which belongs in its own phase.

Some statements cannot be jailed in a transaction at all:

-- runs outside any transaction block — do NOT wrap it yourself
CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);

CONCURRENTLY builds the index without blocking writes by accepting the cost of doing it transaction-lessly — if it fails halfway it leaves an invalid index that must be dropped and retried. Migration tools handle this with per-file directives (goose's -- +no-transaction, golang-migrate's splitting), which is a concrete reason not to hand-roll db.Exec(string(fileContents)) around a wrapping transaction: your wrapper turns a supported statement into a runtime error.

Verification closes the loop. CI builds a database from zero — migrate up to head, run the test suite against it, optionally migrate down one step and back up — proving fresh installs work, not just incremental ones. Production's next migration then runs on a schema CI has already reproduced. The test costs a pipeline minute and deletes the entire class of "works on the databases we migrated incrementally." Schema changes now arrive with the same evidence code does — which is the whole point of versioning them next to it (pool implications of long DDL are the remaining consideration on big tables).

Quiz

1. Five replicas run migrate.Up() in main() on startup during a rolling deploy. What is the fundamental defect and the standard fix?

Question 1 options

2. You must add customer_id and backfill 200M rows on a live orders table. Which plan is deploy-safe?

Question 2 options

3. A migration containing CREATE INDEX CONCURRENTLY fails with 'cannot run inside a transaction block'. What went wrong?

Question 3 options

4. A colleague edits migration 000004 to fix a typo after it already ran in production. Why is this a defect?

Question 4 options

Flashcards

Question

What does a migration runner persist, and how does it decide what to run next?

Answer

A bookkeeping table (usually schema_migrations) recording which versions this database has applied — often with checksums. Pending work = files whose version exceeds the recorded max; they run in order, each recorded after success, and any failure stops the line.

Question

Why must migrations run from one authority instead of every replica's startup?

Answer

Concurrent runners race on schema_migrations, double-apply DDL, or crash mid-sequence across pods. Either the deploy pipeline applies them before code serves traffic, or in-app startup serializes on an advisory lock — one actor, verified state, failure stops before traffic.

Question

What are the three phases of expand-migrate-contract and what does each guarantee?

Answer

Expand adds the new structure (nullable column, new table) while old code keeps working; migrate backfills data in short batches; contract enforces constraints and drops the old path in a later deploy. Each phase leaves old and new code simultaneously runnable — the requirement a rolling deploy imposes.

Question

Which migration statements cannot run inside a transaction, and how do tools cope?

Answer

Postgres statements like CREATE INDEX CONCURRENTLY (and other CONCURRENTLY variants) run outside transaction blocks. Migration tools support per-file no-transaction directives or statement splitting; a runner that always wraps files in tx will fail — and can leave a half-built invalid index if it force-kills.

Question

Why should a down migration exist if production almost never rolls back?

Answer

Developers and CI revert constantly: migrate down lets local databases and test fixtures return to a previous state without hand-dropped tables. Production instead prefers the forward fix (another up migration), but the down file is what keeps dev loops and CI honest.

Revision Notes

Key Takeaways

  • 1. Schema is versioned history: environments converge by replaying ordered files, not by comparing hand-run DDL
  • 2. Exactly one actor applies migrations — deploy step by default, advisory lock on a dedicated session otherwise
  • 3. Rolling deploys forbid atomic assumptions: expand-migrate-contract keeps old and new code compatible at every step
  • 4. CONCURRENTLY-style statements escape transactions — the runner must honor per-file directives, not blanket Exec-in-tx
  • 5. Applied files are immutable; edits fork reality — new migration forward, checksums to enforce it, CI on a fresh database to prove it

Interview Tips

  • • "How do you handle database migrations?" — versioned files, recorded in schema_migrations, applied by one authority in the pipeline; be ready to defend why not on app startup
  • • "Add a column to a huge live table" — expand/migrate/contract with batched backfill; mention rolling-deploy compatibility and CONCURRENTLY for indexes
  • • "Two services share one database — who migrates?" — a single owner per database or a dedicated migrate job; never both apps racing on the same schema_migrations
  • • "Schema changed and old pods are still running — what went wrong?" — a contract-phase change shipped too early; explain the compatibility window and why the risky step waits a deploy

Cheat Sheet

Schema Migrations

File discipline: ordered versions (000012_...), one change per file, -- +up / -- +down, immutable once applied, embedded via //go:embed migrations/*.sql.

Who runs them:

Setup Authority Failure surfaces
deploy step (preferred) CI/CD before new code serves pipeline, blocks release
in-app startup pg_try_advisory_lock on one session (db.Conn) boot, blocks pod

Risk triage:

Change Shape
add column (nullable / instant default) single expand migration
backfill / rewrite / rename / validate constraint expand -> batched migrate -> contract
index on live table CREATE INDEX CONCURRENTLY in a no-transaction file

Template:

//go:embed migrations/*.sql
var migrationFS embed.FS
// read files -> compare to schema_migrations -> apply pending in order
// record version after each success; first failure aborts the run

CI gate: fresh DB, migrate up to head, full test suite, optional down/up cycle — proves installs, not just incremental upgrades.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading