The ALTER TABLE lock matrix, CREATE INDEX CONCURRENTLY, NOT VALID + VALIDATE CONSTRAINT two-phase pattern, adding NOT NULL columns safely on 500M-row tables, column rename via view aliasing, lock_timeout + retry in migration tools, and the exact migration sequences that have taken production sites down.
P-10 — Zero-Downtime Schema Migrations
The most dangerous moment in any PostgreSQL application's life is not when the database is under load — it's when someone runs an ALTER TABLE on a 500-million-row table during business hours. The table locks. Queries queue. The queue hits connection limits. The application goes down. This module is the playbook for never letting that happen.
The ALTER TABLE Lock Matrix
Every schema change acquires a lock. Most acquire AccessExclusiveLock — the most restrictive lock, which blocks ALL reads and writes for the duration. Know what locks what:
| Operation | Lock Level | Blocks reads? | Blocks writes? | Safe under load? |
|---|---|---|---|---|
CREATE INDEX CONCURRENTLY | ShareUpdateExclusiveLock | No | No | Yes |
CREATE INDEX | ShareLock | No | Yes | No |
ADD COLUMN (nullable, no default) | AccessExclusiveLock | Yes | Yes | Brief |
ADD COLUMN ... DEFAULT ... — constant default (PG11+) | AccessExclusiveLock | Yes | Yes | Brief (metadata-only) |
ADD COLUMN ... DEFAULT ... — volatile default (now(), gen_random_uuid()) | AccessExclusiveLock | Yes | Yes | No — full table rewrite, even on PG18 |
ADD COLUMN ... NOT NULL (no default, non-empty table) | — | — | — | Fails immediately with a constraint violation — not a slow rewrite, it doesn't run at all |
ALTER COLUMN TYPE — widening varchar(N)/numeric precision | AccessExclusiveLock | Yes | Yes | Brief (metadata-only, no rewrite) |
ALTER COLUMN TYPE — changing the underlying type (e.g. integer→text) | AccessExclusiveLock | Yes | Yes | No — full table rewrite |
ADD CONSTRAINT ... NOT VALID | ShareRowExclusiveLock | No | Yes, briefly (conflicts with the RowExclusiveLock used by INSERT/UPDATE/DELETE) | Brief |
VALIDATE CONSTRAINT | ShareUpdateExclusiveLock | No | No | Yes |
DROP COLUMN | AccessExclusiveLock | Yes | Yes | Brief (mark only) |
RENAME COLUMN | AccessExclusiveLock | Yes | Yes | Lock is brief, like DROP COLUMN — but unsafe for a different reason: application code referencing the old name breaks the instant the rename commits |
RENAME TABLE | AccessExclusiveLock | Yes | Yes | Lock is brief — same application-compatibility risk as RENAME COLUMN, not a locking problem |
"Brief" means the lock duration is proportional to the table's metadata size, not its row count. Still dangerous under high concurrency because queued queries accumulate. A 50ms metadata-only lock on a table receiving 2,000 queries/second can queue 100 requests before it releases. NOT VALID constraints skip the row-scan validation step, but they still take a real (if brief) lock to add the constraint's catalog entry — that's the ShareRowExclusiveLock, not the more lenient ShareUpdateExclusiveLock used by VALIDATE CONSTRAINT's separate scan phase.
CREATE INDEX CONCURRENTLY — Safe Indexing on Live Tables
Standard CREATE INDEX acquires a ShareLock that blocks all writes for the entire duration. On a 100M row table, that's 5–30 minutes of blocked writes.
CREATE INDEX CONCURRENTLY builds the index without blocking:
The mechanics: CONCURRENTLY makes two passes over the table. Between passes, it waits for any transactions that started before the first pass to complete. Total time is 2–3x longer than standard CREATE INDEX, but zero write blocking.
The failure case: If CREATE INDEX CONCURRENTLY fails mid-build (a unique constraint violation discovered during the build, a statement timeout, a killed connection), it leaves an INVALID index:
An INVALID index is invisible to the query planner but still accumulates write overhead — every INSERT, UPDATE, DELETE on the table touches the INVALID index. Drop it immediately:
Always check for INVALID indexes after your migration pipeline runs. Make it part of your post-migration health check.
Adding NOT NULL Columns Safely
Pre-PostgreSQL 11: Adding any column with a default value rewrites the entire table — every row gets the new column with the default value written to disk. On a 500M row table, this holds AccessExclusiveLock for hours.
PostgreSQL 11+: Adding a column with a NOT NULL DEFAULT that is a constant (not a function call, not now(), not gen_random_uuid()) uses metadata-only storage. The default is stored in the table's catalog, not in each row. The ALTER is instant regardless of table size.
The three-step pattern acquires AccessExclusiveLock twice — but both times for only metadata operations. The expensive work (scanning and writing rows) happens in the UPDATE, which holds no table lock beyond normal row-level locks.
For very large tables, batch the UPDATE:
The NOT VALID + VALIDATE CONSTRAINT Two-Phase Pattern
Adding a foreign key or check constraint with plain ADD CONSTRAINT scans every existing row to verify the constraint holds. On large tables, this holds ShareRowExclusiveLock (blocks concurrent inserts and updates) for minutes.
The two-phase approach:
Phase 1: Add the constraint as NOT VALID — no historical rows are checked, only rows written after this point are validated. This is fast regardless of table size.
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion