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.
Operations by Risk Level
| Operation | Risk | Notes |
|---|---|---|
| ADD COLUMN nullable (no default) | Safe | Instant in all PG versions |
| ADD COLUMN with default (PG 11+) | Safe | Instant, stored in catalog |
| CREATE INDEX CONCURRENTLY | Safe | No lock, 2-3x slower |
| CREATE TABLE | Safe | No existing data affected |
| ADD COLUMN with default (PG <11) | Caution | Full table rewrite |
| ADD CHECK CONSTRAINT NOT VALID | Caution | Instant, validate separately |
| DROP COLUMN | Caution | Irreversible, use expand-contract |
| CREATE INDEX (regular) | Dangerous | Exclusive lock on large tables |
| ALTER COLUMN SET NOT NULL | Dangerous | Full scan, use CHECK NOT VALID |
| RENAME COLUMN / TABLE | Dangerous | Breaks running app code |
| TRUNCATE / DROP TABLE | Critical | Irreversible 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
- ☐ Test migration against a production-size database snapshot (not just dev data)
- ☐ Time the migration on staging — estimate production duration
- ☐ Write and test the rollback script
- ☐ Take a database snapshot immediately before deploying
- ☐ Ensure new app code is backward-compatible with both old and new schema
- ☐ Confirm migration runs in a transaction (for rollback on failure)
- ☐ Check for long-running queries that would block DDL operations
- ☐ Set lock_timeout and statement_timeout to fail fast instead of queue building
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
- Renaming columns directly — Breaks all running app code; use expand-contract instead
- Dropping columns in the same deploy as removing app code — Race condition; always contract after all app instances are updated
- Regular CREATE INDEX on large tables — Takes exclusive lock; always use CONCURRENTLY in production
- Running migrations in the same transaction as app startup — Long migrations block app startup and cause health check failures
- Testing only on small datasets — Index creation and full-table scans are 100x slower on production data
- No rollback plan — Always write and test the down migration before deploying
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.
Related Products
on GumroadPremium Workflow Pack
Production-grade workflows, architecture guides, and deployment scripts for teams that ship. 10 advanced CI/CD workflows (blue-green, canary, E2E, security), 5 architecture deep-dives, code review checklist, PR process guide, and 5 battle-tested Bash deployment scripts.
Buy on Gumroad →DevPlaybook Interview Vault
210+ real technical interview questions with model answers, asked at Google, Meta, Amazon, Microsoft, Stripe, and Shopify. Covers Frontend (55), Backend (55), System Design (50), and DSA (50). Available in English and Traditional Chinese.
Buy on Gumroad →GitHub Actions CI/CD Templates Pack
12 production-ready GitHub Actions workflow templates covering Node.js, Python, Docker, multi-environment deployments, security scanning, and auto-changelog. Copy, paste, ship.
Buy on Gumroad →You Might Also Need
Neon DB Query Builder
Visual query builder for Neon serverless PostgreSQL. Generate raw SQL, @neondatabase/serverless TypeScript, and Drizzle ORM code for SELECT, INSERT, UPDATE, DELETE operations.
Open free tool →
SQL ERD Diagram Generator
Paste SQL CREATE TABLE statements to instantly visualize your database schema as an entity-relationship diagram. Shows tables, column types, primary keys, foreign keys, and relationships.
Open free tool →
PostgreSQL Connection String Builder
Generate PostgreSQL connection strings for all major drivers: libpq URI, JDBC, node-postgres, Prisma, SQLAlchemy, psycopg2 DSN. Handles URL encoding and SSL.
Open free tool →
More Free Tools
Related Articles
PostgreSQL vs MySQL: Which to Choose in 2026?
Comprehensive comparison of PostgreSQL and MySQL in 2026. Performance, JSON support, replication, licensing, and a decision framework for choosing the right database for your project.
PostgreSQL vs MySQL 2026: Which Database Should You Choose?
A comprehensive comparison of PostgreSQL and MySQL in 2026. Deep dive into performance, JSON support, replication, ecosystem, and which database fits your use case.
PostgreSQL Performance Tuning: Indexing Strategies Every Developer Should Know
Master PostgreSQL indexing in 2026: EXPLAIN ANALYZE, B-tree vs GIN vs GiST indexes, partial indexes, covering indexes, and real-world optimization patterns. With code examples and query plans.
Flyway vs Liquibase vs Prisma Migrate vs Alembic: Database Migration Tools 2026
A deep comparison of Flyway, Liquibase, Prisma Migrate, and Alembic for database migrations in 2026 — covering version control strategies, rollback support, multi-DB compatibility, and team workflows.
SQLite vs PostgreSQL vs MySQL: Database Selection Guide 2026
A comprehensive technical comparison of SQLite, PostgreSQL, and MySQL for 2026 — covering ACID compliance, performance benchmarks, JSON support, replication, and which database to choose for your use case.
Enjoying this tool? Get more like it.
Weekly developer tool guides, productivity playbooks & AI tricks. Free. No spam.
Unlock AI-Powered Dev Tools
- ⚡ AI Code Review, Doc Generator & SQL Builder
- ⚡ All premium templates & early access
- ⚡ Member discounts on Gumroad products
$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.
MIT licensed · Instant download · No subscription
See what's included → Browse all deals →