Warming up the neural circuits...
By the end of this chapter you will:
Your database schema will change. It has to — the product evolves, requirements shift, you discover a better design. But changing a live database with millions of rows and thousands of concurrent users is like replacing the engine of a plane while it's in the air. Migrations are the procedure for doing that safely.
A city can't shut down all roads to add a new lane. Instead, they:
Database migrations follow the same pattern: expand first, migrate data, contract last. Never do all three in one step — that's how you cause downtime.
Every migration tool (Prisma Migrate, Drizzle Kit, Flyway, golang-migrate) follows the same pattern:
1. Modify the schema definition (schema.prisma, TypeScript, SQL)
2. Generate a migration file (timestamped SQL file)
3. Review the migration SQL (the most important step!)
4. Apply the migration to development → staging → production
5. Commit the migration file to Git-- prisma/migrations/20250115000000_add_user_bio/migration.sql
-- This is what Prisma generates. READ IT before applying.
ALTER TABLE "users" ADD COLUMN "bio" TEXT;
-- Every migration file is immutable once applied to production.
-- Never edit a migration that's already been deployed.Any schema change that would block reads or writes must be done in three phases:
Phase 1 — Expand: Add new columns/tables. Old code ignores them. New code can start using them. Phase 2 — Migrate: Backfill data, run both old and new code paths. Phase 3 — Contract: Remove old columns/tables once all code has been updated.
-- Goal: Rename "full_name" to "display_name" without downtime
-- Phase 1: EXPAND — add the new column (nullable)
ALTER TABLE users ADD COLUMN display_name TEXT;
-- Deploy code that writes to BOTH columns:
-- INSERT INTO users (full_name, display_name) VALUES ($1, $1);
-- UPDATE users SET full_name = $1, display_name = $1 WHERE id = $2;
-- Phase 2: MIGRATE — backfill existing rows
UPDATE users SET display_name = full_name WHERE
This is the scariest migration. Adding a NOT NULL column to a 50M-row table without a default value requires locking the entire table while PostgreSQL checks that every row satisfies the constraint. The safe approach:
-- Step 1: Add as nullable (instant in PG 11+, metadata-only)
ALTER TABLE users ADD COLUMN preferred_language TEXT;
-- Step 2: Add a DEFAULT (instant — new rows get the default. Old rows stay NULL.)
ALTER TABLE users ALTER COLUMN preferred_language SET DEFAULT 'en';
-- Step 3: Backfill existing rows in batches
-- Run this in a script, not inside a migration (could take hours)
UPDATE users SET preferred_language =
| Anti-pattern | Why it's dangerous | Safe alternative |
|---|---|---|
| Editing a deployed migration file | The migration hash changes. Next deploy detects drift and fails or silently skips. | Always create a NEW migration that undoes the mistake. Never edit history. |
| Running migrations manually on production | Nobody else knows the schema changed. The migration history table is out of sync with reality. | Always go through the migration tool (Prisma Migrate, Flyway, etc.) |
DROP COLUMN without checking what uses it | Deployed code still references the column → 500 errors | Expand-contract: remove code references first, then drop the column in a later migration |
RENAME COLUMN in one migration | Deployed code references the old name → 500 errors. No rollback path. | Add new column (expand), dual-write, migrate reads, drop old column (contract) |
| Long-running migration in a | Locks the table for the duration. For 50M rows, could be hours. | Break into small batches. Use CONCURRENTLY for creation. Never run a 2-hour ALTER TABLE in a single transaction. |
Every migration must be backward compatible with the currently deployed code. Your migration runs before or during the deploy. The old code is still running. If you drop a column the old code queries, you have an outage. Always expand first, contract later.
Rollback procedures, from best to worst:
down migration (or Prisma's migrate diff can generate one). Apply it. Tested? Good.Before any production migration: verify that your latest backup is restorable. Run pg_restore against a staging database. A backup you haven't tested is not a backup — it's a hope.
Shopify runs thousands of database migrations per year across hundreds of databases. Their process:
CONCURRENTLY and batched operations are allowed.| Mistake | Why it's wrong | What to do instead |
|---|---|---|
| Renaming a column with a single ALTER | The currently running code references the old column name → immediate outage | Expand-contract: add new column, dual-write, migrate reads, drop old column |
| Running migrations inside application startup | If the migration fails, the app crashes. If 10 instances start simultaneously, they all try to migrate. | Run migrations as a separate step in before deploying the app |
DROP TABLE accidentally in production | No confirmation prompt, no undo — table is gone instantly | Use DROP TABLE IF EXISTS (safer but still permanent). Better: rename to _old first, verify nothing breaks, then drop later |
Adding an index without CONCURRENTLY | CREATE INDEX locks the table for writes for the duration (minutes to hours on large tables) | Always CREATE INDEX CONCURRENTLY in production |
Not setting statement_timeout for migrations | A migration that hangs (waiting for a lock) blocks all subsequent queries forever | SET statement_timeout = '60s'; before running migrations |
| Migrating 10M rows in one UPDATE | One long-running transaction holds locks, bloats the WAL, and blocks vacuum | Batch updates: UPDATE ... WHERE id > $cursor LIMIT 5000; in a loop with brief pauses |
lock_timeout for migrations. If a migration can't acquire a lock within N seconds, it fails instead of blocking other queries.UPDATE on 10M rows in one query. Use LIMIT and a cursor (WHERE id > $lastId), with brief sleeps between batches to avoid overwhelming replication.CREATE INDEX CONCURRENTLY is ~2x slower but doesn't block writes. Always use it in production.VACUUM after large UPDATE/DELETE migrations to reclaim space and update statistics.User model. Generate and apply a migration. Inspect the generated . Then add a bio column, generate a new migration, and apply it.UP migration that adds a column and a DOWN migration that drops it. Apply both and verify.users table with full_name. Add display_name, dual-write, backfill, switch reads, drop full_name. Write the script for each phase.NOT NULL column with a default to a table with 1M rows (use generate_series). Time the migration with and without the safe approach (add nullable → backfill → set NOT NULL).DROP TABLE, RENAME COLUMN, CREATE INDEX (without CONCURRENTLY), or ALTER TABLE ... ADD COLUMN ... NOT NULL (without a DEFAULT).What is a database migration? A version-controlled SQL script that changes the database schema — adding/removing tables, columns, indexes, or constraints. Migrations are applied in order, and each one is tracked so you never apply the same migration twice.
Why shouldn't you edit a migration file that's already been applied to production? The migration tool tracks which migrations have run via a hash or checksum. Editing a deployed migration changes the hash, causing the tool to detect "drift" and either fail or re-apply changes that may already exist.
What does CREATE INDEX CONCURRENTLY do differently? It builds the index without locking the table for writes, allowing normal operations to continue. It takes longer and can't run inside a transaction.
Explain the expand-contract pattern for zero-downtime migrations. Three phases: (1) Expand — add new columns/tables without removing old ones. Deploy code that writes to both old and new. (2) Migrate — backfill data from old to new. Deploy code that reads from new with old as fallback. (3) Contract — remove old columns/tables only after all code references have been updated and verified. This ensures that at no point does the schema change break the currently running code.
How do you add a NOT NULL column to a 50-million-row table without downtime? (1) Add as nullable (instant in PG 11+). (2) Set a DEFAULT value (instant). (3) Backfill existing rows in batches using a cursor-based loop with LIMIT. (4) Once all rows have values, set NOT NULL (validates the constraint). This avoids locking the table while checking 50M rows.
What's your rollback strategy if a migration fails halfway through? Ideally: the migration is designed to be reversible — an up and down script exist. If partially applied (e.g., column added but backfill failed), determine if the partial is safe. If safe, complete the backfill manually. If not safe, write and apply a compensating migration. Last resort: restore from backup. This is why you test backups before migrating.
Migrations are how databases evolve safely. The expand-contract pattern lets you change schemas without downtime: add first, migrate data, remove later. Always review generated SQL. Always have a rollback plan. Never edit deployed migrations. Add NOT NULL columns in phases. Use CONCURRENTLY for indexes. Batch large data changes. Test your backups.
CREATE INDEX CONCURRENTLY — always in production.lock_timeout and statement_timeout.CREATE INDEX CONCURRENTLY avoid? Table write locks — it builds the index without blocking INSERTs, UPDATEs, or DELETEs.statement_timeout protect against during migrations? A migration that hangs waiting for a lock — after the timeout, it fails instead of blocking all other queries indefinitely.