Database Migration Strategies Cheat Sheet
Covers schema migration tooling, versioning, and safe rollout patterns like expand-contract for changing production database schemas without downtime.
Migration Fundamentals
Core ideas behind versioned schema migrations.
- Versioned migrations- Each schema change is a numbered/timestamped file applied in order and tracked in a migrations table, giving a reproducible history
- Up / down migrations- The 'up' script applies a change; the 'down' script reverses it, enabling rollback
- Idempotent migrations- Migrations written so re-running them (e.g., after a partial failure) doesn't error or duplicate changes (IF NOT EXISTS guards)
- Backward-compatible change- A schema change that doesn't break code still running the previous version, essential when deploying app and DB changes separately
- Migration lock- Most migration tools take a lock so two deploys can't run migrations concurrently and corrupt the schema state
Expand-Contract Pattern
Rename a column safely across multiple deploys.
-- Step 1 (expand): add the new column, nullable, deploy app that writes to BOTH columnsALTER TABLE users ADD COLUMN full_name TEXT;-- Step 2 (backfill): populate the new column from the old one in batchesUPDATE users SET full_name = first_name || ' ' || last_nameWHERE full_name IS NULL;-- Step 3: deploy app version that reads from full_name, still writes both-- Step 4 (contract): once all instances are on the new version, drop the old columnsALTER TABLE users DROP COLUMN first_name;ALTER TABLE users DROP COLUMN last_name;
Migration File (Knex.js)
A reversible migration with up and down functions.
exports.up = function (knex) { return knex.schema.alterTable('users', (table) => { table.string('full_name'); });};exports.down = function (knex) { return knex.schema.alterTable('users', (table) => { table.dropColumn('full_name'); });};// CLI usage:// npx knex migrate:make add_full_name_to_users// npx knex migrate:latest// npx knex migrate:rollback
Zero-Downtime Checklist
Practices that keep migrations safe under a rolling deploy.
- Avoid long locks- Adding a NOT NULL column with a default can rewrite the whole table on older Postgres versions; add nullable, backfill, then add a NOT NULL constraint with NOT VALID + VALIDATE
- Don't rename/drop in one step- Renaming a column or table breaks any code still deployed against the old name; do it in expand-contract stages instead
- Batch backfills- Update rows in small chunks (e.g., 1,000 at a time) with a short pause between batches to avoid long-running transactions and replication lag
- Create indexes concurrently- Use CREATE INDEX CONCURRENTLY (Postgres) or pt-online-schema-change (MySQL) to avoid locking writes during index creation
- Feature-flag risky changes- Gate new-column usage behind a flag so you can roll forward/back the app independent of the schema state
Blue-Green Schema Cutover
Use logical replication to migrate with near-zero cutover time.
-- On the source (blue) database: create a publication for changed tablesCREATE PUBLICATION migration_pub FOR TABLE users, orders;-- On the target (green) database: subscribe to stream ongoing writesCREATE SUBSCRIPTION migration_sub CONNECTION 'host=blue-db dbname=app' PUBLICATION migration_pub;-- Monitor replication lag until it reaches ~0 before cutoverSELECT slot_name, confirmed_flush_lsn, pg_current_wal_lsn()FROM pg_replication_slots;-- Cutover: stop writes to blue, wait for lag to drain, repoint app to greenDROP SUBSCRIPTION migration_sub;
Adding NOT NULL Without a Table Rewrite
Postgres-specific pattern to avoid locking large tables.
-- 1. Add the column nullable (fast, metadata-only change)ALTER TABLE users ADD COLUMN status TEXT;-- 2. Backfill in batches (application code or a loop), not in one giant UPDATE-- UPDATE users SET status = 'active' WHERE id BETWEEN 1 AND 1000 AND status IS NULL;-- 3. Add the constraint as NOT VALID (instant, no scan, no lock)ALTER TABLE users ADD CONSTRAINT users_status_not_null CHECK (status IS NOT NULL) NOT VALID;-- 4. Validate separately (scans but only takes a SHARE UPDATE EXCLUSIVE lock, not exclusive)ALTER TABLE users VALIDATE CONSTRAINT users_status_not_null;-- 5. Once satisfied, promote to a real NOT NULL (fast now that the check exists)ALTER TABLE users ALTER COLUMN status SET NOT NULL;ALTER TABLE users DROP CONSTRAINT users_status_not_null;
Reversible Migration (Alembic)
Python/SQLAlchemy migration with explicit upgrade/downgrade steps.
"""add index concurrently to orders.status"""from alembic import opimport sqlalchemy as sa# Concurrent index builds can't run inside a transaction blockrevision = 'a1b2c3d4'down_revision = 'f9e8d7c6'def upgrade(): with op.get_context().autocommit_block(): op.create_index( 'ix_orders_status', 'orders', ['status'], postgresql_concurrently=True, )def downgrade(): with op.get_context().autocommit_block(): op.drop_index( 'ix_orders_status', table_name='orders', postgresql_concurrently=True, )
Failure Modes at Scale
Advanced hazards that only appear with large tables or high write volume.
- Replication lag amplification- A large batched backfill can generate more WAL/binlog than replicas can apply, causing read-replica lag to spike; throttle batch size and add sleep intervals
- Lock queue pileup- Even a fast DDL statement can queue behind a long-running transaction holding a conflicting lock, then block every subsequent query behind it; set a short `lock_timeout` and retry rather than waiting indefinitely
- Dual-write divergence- Writing to both old and new columns/tables during expand-contract can drift if one write succeeds and the other fails; wrap dual writes in the same transaction or use a CDC pipeline instead of app-level dual writes
- Enum value additions- Adding a value to a Postgres native ENUM type cannot run inside the same transaction as other DDL and cannot be used immediately in the same transaction it was added in; prefer a CHECK constraint or lookup table for enums that change often
- Foreign key validation cost- Like CHECK constraints, new foreign keys support `NOT VALID` + `VALIDATE CONSTRAINT` to avoid a blocking full-table scan when adding referential integrity to an existing large table
- Migration ordering across services- In a microservices setup, a shared-database migration must be compatible with every service version currently deployed, not just the one deploying it — coordinate via contract tests or a shared migration gate
Always assume old and new application code will run against the same database simultaneously during a rolling deploy — design every migration to be safe for both versions at once (expand-contract), never a single atomic cutover.