Database Migrations in Production
Execute zero-downtime database schema changes safely in production PostgreSQL and MySQL systems.
Overview
Overview
Database migrations are among the highest-risk operations in production. A naive ALTER TABLE on a large table can take a lock for minutes or hours, block reads and writes, and cause a full outage. Knowing how the database executes DDL, and which operations are safe at what scale, is what lets you migrate without downtime.
Why a naive migration causes an outage
PostgreSQL controls DDL with lock modes. The dangerous ones need a full table scan or rewrite while holding a strong lock.
- ALTER TABLE ... SET NOT NULL — AccessExclusiveLock, conflicts with everything including SELECT — Scans every row to validate; no reads or writes meanwhile
- ADD COLUMN with a default that must be written to every row — AccessExclusiveLock — Rewrites the table
- Plain CREATE INDEX — ShareLock — Reads continue, but every write blocks until the build finishes
- ADD CONSTRAINT ... NOT VALID — Brief lock, catalog only — Instant, does not scan existing rows
- VALIDATE CONSTRAINT — ShareUpdateExclusiveLock — Scans the table while reads and writes continue
On a 100-million-row table, a migration that scans the full table (backfilling a default, validating NOT NULL, building an index) can hold its lock for 10-30 minutes.
> [!NOTE] Corrected: a plain CREATE INDEX takes a ShareLock, which blocks writes but not reads; it is not an AccessExclusiveLock.
Expand-contract: adding a NOT NULL column safely
- 1 — Add the column as nullable with no default — Instant: only the catalog changes, not the rows
- 2 — Deploy code that writes the new column (and the old one) — Existing rows stay valid while the column is nullable
- 3 — Backfill existing rows in batches, with sleeps between batches — Avoids I/O saturation
- 4 — ALTER TABLE table ADD CONSTRAINT col_not_null CHECK (col IS NOT NULL) NOT VALID, then ALTER TABLE table VALIDATE CONSTRAINT col_not_null — Validation takes only a ShareUpdateExclusiveLock. In PostgreSQL 12+, a later SET NOT NULL uses the validated check and skips the full scan
- 5 — Deploy code reading the new column — The data is complete and enforced
- 6 — Drop the old column — Nothing reads it any more
CREATE INDEX CONCURRENTLY
CREATE INDEX CONCURRENTLY builds an index without blocking reads or writes. It registers the index, scans the table to build it, makes a second pass for rows changed meanwhile, and finally marks the index valid.
- Duration — Takes longer, about 2-3x a normal index build
- Failure mode — Can fail partway and leave an INVALID index that must be dropped and rebuilt
- Verification — After the build, confirm pg_index.indisvalid is true for the index (\d table also marks it INVALID)
> [!WARNING] pg_indexes.indexdef does not show validity; check pg_index.indisvalid after every concurrent build.
MySQL: online schema change tools
gh-ost and pt-online-schema-change make virtually any MySQL schema change safe regardless of table size.
Rollback plan before you run
- Reversible, such as adding a column the code uses optionally — Verify the application runs correctly without the migration; rolling back is straightforward
- Irreversible, such as dropping a column or removing a unique constraint — Point-in-time backup immediately before the migration, plus a tested restore procedure
> [!IMPORTANT] Every migration needs its rollback plan written down before it runs.
Key concepts
- AccessExclusiveLock
- PostgreSQL lock mode required by most DDL operations that conflicts with all other lock types, blocking reads and writes on the target table for the migration duration.
- Expand-Contract Pattern
- A zero-downtime migration strategy that adds new schema elements (expand), migrates data and code, then removes old schema elements (contract) across multiple coordinated deployments.
- CREATE INDEX CONCURRENTLY
- A PostgreSQL DDL command that builds an index without holding a table lock, allowing reads and writes to continue — at the cost of longer build time and the possibility of leaving an INVALID index.
- NOT VALID Constraint
- A PostgreSQL constraint option that adds a constraint to the catalog without scanning existing rows, followed by VALIDATE CONSTRAINT to check existing rows under a lower-impact lock.
- Backfill
- The process of populating a new column with computed values for existing rows, done in batches with sleep intervals to avoid I/O saturation and replication lag.
When to use it
- Planning a schema change on a large production table without taking downtime
- Explaining the expand-contract migration pattern for zero-downtime schema evolution
- Choosing between pg_repack, pt-osc, and gh-ost for online schema changes
- Diagnosing why an ALTER TABLE is blocking production reads and writes
- Designing the backfill phase of a multi-phase migration safely
Interview questions
- Walk through the safe zero-downtime migration sequence for adding a NOT NULL column with a default to a 500-million-row PostgreSQL table.
- What happens when CREATE INDEX CONCURRENTLY fails partway through, and how do you recover?
- How do you safely backfill a large column in PostgreSQL without causing replication lag or I/O saturation?
Sign up free to read the full topic
More Troubleshooting topics
Start free on Cariara · Coding problems · System design studio