Skip to main content

Database Migration Checklist

A complete runbook for safe, zero-downtime database migrations. Covers the expand-contract pattern, dangerous operations to avoid, and a step-by-step deployment checklist.

✓ Zero-downtime techniques ✓ Expand-contract pattern ✓ Rollback plans ✓ CI/CD deployment order

Operations by Risk Level

Operation Risk Notes
ADD COLUMN nullable (no default)SafeInstant in all PG versions
ADD COLUMN with default (PG 11+)SafeInstant, stored in catalog
CREATE INDEX CONCURRENTLYSafeNo lock, 2-3x slower
CREATE TABLESafeNo existing data affected
ADD COLUMN with default (PG <11)CautionFull table rewrite
ADD CHECK CONSTRAINT NOT VALIDCautionInstant, validate separately
DROP COLUMNCautionIrreversible, use expand-contract
CREATE INDEX (regular)DangerousExclusive lock on large tables
ALTER COLUMN SET NOT NULLDangerousFull scan, use CHECK NOT VALID
RENAME COLUMN / TABLEDangerousBreaks running app code
TRUNCATE / DROP TABLECriticalIrreversible data loss

The Expand-Contract Pattern

-- GOAL: Rename column "full_name" → "display_name" without downtime

-- PHASE 1: EXPAND (deploy migration first, old app still works)
ALTER TABLE users ADD COLUMN display_name TEXT;

-- PHASE 2: BACKFILL (background job, batch processing)
UPDATE users SET display_name = full_name WHERE display_name IS NULL LIMIT 10000;
-- Repeat in batches until complete

-- PHASE 3: SYNC (keep both in sync during transition via trigger or app code)
-- Trigger approach:
CREATE OR REPLACE FUNCTION sync_display_name()
RETURNS TRIGGER AS $$
BEGIN
  NEW.display_name := COALESCE(NEW.display_name, NEW.full_name);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sync_display_name
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_display_name();

-- PHASE 4: DEPLOY new app code that reads display_name

-- PHASE 5: CONTRACT (after all app instances use display_name)
DROP TRIGGER trg_sync_display_name ON users;
ALTER TABLE users DROP COLUMN full_name;

Safe NOT NULL Migration

-- Step 1: Add column as nullable (instant)
ALTER TABLE orders ADD COLUMN status TEXT;

-- Step 2: Backfill existing rows in batches (no lock)
UPDATE orders SET status = 'pending'
WHERE id IN (SELECT id FROM orders WHERE status IS NULL LIMIT 5000);
-- Repeat until no nulls remain

-- Step 3: Add CHECK NOT VALID (instant — only validates future writes)
ALTER TABLE orders
  ADD CONSTRAINT orders_status_not_null
  CHECK (status IS NOT NULL) NOT VALID;

-- Step 4: VALIDATE CONSTRAINT during low-traffic window
-- Uses ShareUpdateExclusiveLock (allows reads, blocks DDL only)
ALTER TABLE orders VALIDATE CONSTRAINT orders_status_not_null;

-- Step 5: PostgreSQL 12+ — SET NOT NULL now skips the table scan
-- because it finds the validated check constraint as proof
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;

-- Step 6: Drop the check constraint (redundant now)
ALTER TABLE orders DROP CONSTRAINT orders_status_not_null;

Pre-Migration Deployment Checklist

Migration Deployment Order (CI/CD)

# Correct order for zero-downtime deployment:
# 1. Deploy migration (expand phase — add new columns/tables)
# 2. Deploy new app code (reads both old and new schema)
# 3. Run backfill jobs (fill new columns)
# 4. Validate migration
# 5. Deploy cleanup (contract phase — drop old columns)

# Flyway / Liquibase:
# - V1__add_display_name.sql  (expand)
# - V2__backfill_display_name.sql
# - V3__drop_full_name.sql    (contract — separate deployment)

# Set timeouts to avoid lock queues
SET lock_timeout = '5s';    -- fail if lock not acquired in 5s
SET statement_timeout = '30s';  -- fail long-running migrations early

Common Migration Mistakes

Frequently Asked Questions

How do I add a column to a large PostgreSQL table without downtime?▼

Adding a column with a default value in PostgreSQL 11+ is instant — Postgres stores the default in the catalog without rewriting the table. For older versions, add the column as nullable first (instant), backfill in batches, then add the NOT NULL constraint. Never run ALTER TABLE ADD COLUMN NOT NULL DEFAULT on a large table in older Postgres under production load — it takes an exclusive lock and rewrites the table. Adding a nullable column with no default is always instant in all Postgres versions.

What is the expand-contract pattern for zero-downtime migrations?▼

The expand-contract (or parallel change) pattern has three phases. Expand: add new columns/tables while keeping old ones — both old and new code paths work. Migrate: backfill data from old columns to new columns in background batches. Contract: once all code is deployed to use new schema and all data is migrated, drop the old columns. This pattern requires deploying app code changes before and after schema changes. Never rename columns or tables directly — add the new name, migrate, then drop the old.

How do I safely add a NOT NULL constraint to an existing column?▼

Adding NOT NULL directly locks the entire table while it scans every row. Safe approach for large tables: (1) Add a CHECK constraint as NOT VALID first — this validates future writes without scanning existing rows: ALTER TABLE users ADD CONSTRAINT users_email_not_null CHECK (email IS NOT NULL) NOT VALID; (2) Backfill any nulls to valid values. (3) Run VALIDATE CONSTRAINT during a low-traffic window — this scans the table but uses a ShareUpdateExclusiveLock (much lighter than exclusive). (4) Finally, ALTER COLUMN SET NOT NULL which recognizes the valid constraint and skips the scan.

What should be in a database migration rollback plan?▼

Every migration needs a down migration. For column additions: drop the column. For column removals (use expand-contract, never delete in-place): re-add the column and restore from backup. For index additions: drop the index. For data transformations: keep the old column until the migration is verified in production. Test rollback in staging before deploying to production. Document the rollback command and estimated duration. For irreversible migrations (data deletes), take a snapshot immediately before deploying.

How do I run database migrations in CI/CD without locking production?▼

Run migrations before deploying new app code (deploy migration, then app). Use migration tools that support --dry-run to preview changes. For PostgreSQL: use CREATE INDEX CONCURRENTLY (no lock) instead of CREATE INDEX. Use ADD COLUMN as nullable first. Wrap migrations in transactions where possible so failures auto-rollback. Test migrations against a production-size data snapshot in staging, not just the small test database — many migrations are fast on 1000 rows but take 10 minutes on 100 million rows.

What is CREATE INDEX CONCURRENTLY and when should I use it?▼

CREATE INDEX CONCURRENTLY builds the index without taking an exclusive lock on the table, allowing reads and writes to continue during index creation. The tradeoff: it takes 2-3x longer than regular CREATE INDEX and cannot run inside a transaction. Use it for all indexes on large production tables. If CONCURRENTLY fails midway, it leaves an INVALID index that you must DROP before retrying. Regular CREATE INDEX is fine for new tables (no existing data to lock) or in maintenance windows. In Prisma/Drizzle migrations, mark indexes as concurrent explicitly.

Share this tool: X / Twitter LinkedIn
✓ 60,000+ developers use DevPlaybook ★★★★★ 4.8 / 5 on Gumroad ✓ MIT licensed · Instant download ✓ No login required

Related Products

on Gumroad

You Might Also Need

More Free Tools

Enjoying this tool? Get more like it.

Weekly developer tool guides, productivity playbooks & AI tricks. Free. No spam.

DevPlaybook Pro 7 days free

Unlock AI-Powered Dev Tools

  • ⚡ AI Code Review, Doc Generator & SQL Builder
  • ⚡ All premium templates & early access
  • ⚡ Member discounts on Gumroad products
Start Free Trial →

$9/mo after trial · Cancel anytime

Want the full toolkit?

Get DevPlaybook Pro

Every template, guide, boilerplate, and automation script in one bundle. 13 premium products — grab them all at once and save big.

$79
$241 separately Save $162 (67% off)
Get DevPlaybook Pro — $79 →

MIT licensed · Instant download · No subscription

See what's included → Browse all deals →