SE

Database migrations

Must-know concept1.5 hAdvanced

Schema evolution, zero-downtime changes.

Schema changes are the riskiest deploys you'll ever do — they're stateful, often non-reversible, and a bad one takes down writes. The "expand and contract" pattern, and a discipline of never combining a column rename with a code change, eliminate 90% of the risk.

The big idea

Run migrations separately from app deploys, and never let one deploy require both old and new code paths to read or write incompatible schemas. The expand-and-contract pattern formalises this.

1. Expandadd new column / table — old code still works
2. Dual-writenew code writes both old + new — both readable
3. Backfillfill the new column for existing rows
4. Migrate readsswitch readers to the new column
5. Contractdrop the old column

Why one-shot migrations break

A "rename username to handle" deploy looks innocent. In production:

  1. The migration runs at deploy time

    Renames the column.

  2. Old app instances still running

    They query SELECT username FROM users and crash.

  3. New instances expect handle

    They query SELECT handle FROM users and work — until they hit a request that was routed mid-rollout.

  4. Rolling back is impossible

    The old column doesn't exist anymore. The old app version can't run.

The same change, done safely

  1. Deploy 1 — expand

    Migration: ALTER TABLE users ADD COLUMN handle TEXT; Code: still reads/writes username. No risk.

  2. Deploy 2 — dual-write

    Code: writes both username and handle. Reads username. Both columns track.

  3. Backfill

    Batched job: UPDATE users SET handle = username WHERE handle IS NULL. Run in chunks of 10k so locks don't pile up.

  4. Deploy 3 — migrate reads

    Code: writes both, reads handle. Verify in production.

  5. Deploy 4 — contract

    Code: writes only handle. Migration: ALTER TABLE users DROP COLUMN username;

Five steps, four deploys. Slow — and safe. Every step is independently revertible.

What's risky in a migration

  • ADD COLAdd column NULL

    Cheap on Postgres. Fast.

  • ADD COL NOT NULLWith default on big table

    Postgres 11+: instant. Older: full table rewrite.

  • ADD INDEXCreate index

    Use CONCURRENTLY in Postgres — otherwise blocks writes.

  • DROP COLDrop column

    Metadata-only in Postgres. Fast. (But your *code* must already stop using it.)

  • RENAMERename column / table

    Cheap in the DB; deadly without expand-and-contract.

  • BACKFILLUPDATE large table

    Locks rows, bloats WAL. Batch in 1–10k chunks with sleeps.

Tool choice

The standard tools enforce ordering and history:

Alembic (SQLAlchemy)
Python; revision graph; upgrade and downgrade.
Prisma Migrate
TypeScript; schema-first; generates SQL.
Flyway / Liquibase
JVM; simple, battle-tested SQL-file approach.
goose / sqlx
Go; lightweight, SQL-file or Go-function migrations.
Rails migrations
Active Record's; one of the originals.

All of them:

  • Write a schema_migrations table tracking which migrations have run.
  • Order migrations by timestamp or version.
  • Refuse to run the same migration twice.
// Knex example: add the new column (expand)
export async function up(knex: Knex) {
  await knex.schema.alterTable('users', (t) => {
    t.text('handle').nullable();
  });
}
 
export async function down(knex: Knex) {
  await knex.schema.alterTable('users', (t) => {
    t.dropColumn('handle');
  });
}

Backfills

A migration that touches every row in a 50M-row table will lock everything for minutes. Don't do it in one statement.

-- Bad: one giant transaction
UPDATE users SET handle = username WHERE handle IS NULL;
 
-- Good: bite-sized batches via a job
WITH batch AS (
  SELECT id FROM users WHERE handle IS NULL LIMIT 5000 FOR UPDATE SKIP LOCKED
)
UPDATE users u SET handle = u.username FROM batch WHERE u.id = batch.id;
-- run this loop until 0 rows affected, sleeping 100ms between batches

In practice

The discipline: schema changes deploy alone, code reads new columns only after a backfill, drops happen in their own deploy after a wait period. Tooling like strong_migrations (Ruby) and squawk (Postgres SQL linter) automatically catches the dangerous patterns.

Key takeaways

  • Migrations are stateful and often non-reversible — treat them as the riskiest deploys.
  • Use expand-and-contract: add, dual-write, backfill, migrate reads, drop.
  • Never combine a rename with a code change in a single deploy.
  • Backfill in chunks; use `CREATE INDEX CONCURRENTLY` for indexes.
  • Pick a migration tool with a schema_migrations table; never edit history once a migration has run in prod.

Checkpoint questions

Use these to test whether the lesson is clear enough to explain without rereading.

  1. 1Why can a simple ALTER TABLE become dangerous on a large production table?
  2. 2What sequence makes a schema change backward compatible during deploys?
  3. 3How would you roll out a new non-null column without downtime?
  4. 4What should a migration plan include before touching production data?

References

External resources for going deeper after the lesson above.