Module P-10·29 min read

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.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

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:

OperationLock LevelBlocks reads?Blocks writes?Safe under load?
CREATE INDEX CONCURRENTLYShareUpdateExclusiveLockNoNoYes
CREATE INDEXShareLockNoYesNo
ADD COLUMN (nullable, no default)AccessExclusiveLockYesYesBrief
ADD COLUMN ... DEFAULT ...constant default (PG11+)AccessExclusiveLockYesYesBrief (metadata-only)
ADD COLUMN ... DEFAULT ...volatile default (now(), gen_random_uuid())AccessExclusiveLockYesYesNo — 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 precisionAccessExclusiveLockYesYesBrief (metadata-only, no rewrite)
ALTER COLUMN TYPE — changing the underlying type (e.g. integertext)AccessExclusiveLockYesYesNo — full table rewrite
ADD CONSTRAINT ... NOT VALIDShareRowExclusiveLockNoYes, briefly (conflicts with the RowExclusiveLock used by INSERT/UPDATE/DELETE)Brief
VALIDATE CONSTRAINTShareUpdateExclusiveLockNoNoYes
DROP COLUMNAccessExclusiveLockYesYesBrief (mark only)
RENAME COLUMNAccessExclusiveLockYesYesLock is brief, like DROP COLUMN — but unsafe for a different reason: application code referencing the old name breaks the instant the rename commits
RENAME TABLEAccessExclusiveLockYesYesLock 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:

sql

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:

sql

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:

sql

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.

sql

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:

sql

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 & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.