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:
-- WRONG — blocks all writes for minutes on large tables
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- CORRECT — builds the index without blocking writes
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);
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:
-- Find INVALID indexes
SELECT
schemaname,
tablename,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_indexes
JOIN pg_class ON relname = indexname
JOIN pg_index ON indexrelid = pg_class.oid
WHERE indisvalid = false;
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:
DROP INDEX CONCURRENTLY idx_orders_user_id; -- drop also non-blocking
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);
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.
-- PG11+ with a constant default — instant, no table rewrite
ALTER TABLE orders ADD COLUMN processed boolean NOT NULL DEFAULT false;
-- PG11+ with a non-constant default — still rewrites the table
ALTER TABLE orders ADD COLUMN created_at timestamptz NOT NULL DEFAULT now();
-- Dangerous on large tables. now() is volatile, so PG must write the value to each row.
-- Safe pattern for volatile defaults: add nullable first, backfill, then constrain
ALTER TABLE orders ADD COLUMN created_at timestamptz; -- instant
UPDATE orders SET created_at = NOW() WHERE created_at IS NULL; -- batch backfill
ALTER TABLE orders ALTER COLUMN created_at SET NOT NULL; -- then constrain
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:
-- Batch backfill to avoid long-running transactions and lock contention
DO $$
DECLARE
batch_size INT := 10000;
last_id BIGINT := 0;
max_id BIGINT;
BEGIN
SELECT MAX(id) INTO max_id FROM orders;
WHILE last_id < max_id LOOP
UPDATE orders
SET created_at = NOW()
WHERE id > last_id
AND id <= last_id + batch_size
AND created_at IS NULL;
last_id := last_id + batch_size;
PERFORM pg_sleep(0.01); -- 10ms pause between batches to let autovacuum keep up
END LOOP;
END $$;
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 & RegisterSee it in production
Discussion
0Join the discussion