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 ... (PG11+) | AccessExclusiveLock | Yes | Yes | Brief |
ADD COLUMN ... NOT NULL (no default) | AccessExclusiveLock | Yes | Yes | No — rewrites table |
ALTER COLUMN TYPE | AccessExclusiveLock | Yes | Yes | No — rewrites table |
ADD CONSTRAINT ... NOT VALID | ShareUpdateExclusiveLock | No | No | Yes |
VALIDATE CONSTRAINT | ShareUpdateExclusiveLock | No | No | Yes |
DROP COLUMN | AccessExclusiveLock | Yes | Yes | Brief (mark only) |
RENAME COLUMN | AccessExclusiveLock | Yes | Yes | No |
RENAME TABLE | AccessExclusiveLock | Yes | Yes | No |
"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.
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.
Phase 2: Validate separately — VALIDATE CONSTRAINT checks existing rows using ShareUpdateExclusiveLock, which allows concurrent reads and most writes.
Why does Phase 2 work? VALIDATE CONSTRAINT uses ShareUpdateExclusiveLock which is compatible with concurrent reads and DML. It does a full table scan but doesn't block your application. Run it during off-peak hours if the table is very large.
The same pattern applies to CHECK constraints:
After Phase 2 completes, the constraint is fully enforced for all rows — both existing and future.
Renaming Columns Safely — The Expand/Contract Pattern
RENAME COLUMN acquires AccessExclusiveLock and breaks any code that references the old column name. Even within a maintenance window, you have to deploy application code atomically with the rename — impossible without downtime unless your deployment is instantaneous.
The zero-downtime rename uses the expand/contract pattern: four deployment steps across days or weeks.
Step 1: Add the new column (nullable)
This is instant. The old columns (first_name, last_name) are untouched. Application code continues to work.
Step 2: Write to both columns in application code (deploy)
Deploy this change. All new rows now have both the old columns and the new column populated.
Step 3: Backfill the new column for existing rows
Run during off-peak hours. For large tables, batch as shown earlier.
Step 4: Add NOT NULL constraint (after backfill completes)
Step 5: Update application to read from new column only (deploy)
Remove all references to first_name and last_name from your read paths. Leave the write path touching all three columns until the next step.
Step 6: Remove the old columns (deploy)
Four deployment steps. No downtime. No application-visible lock during any step except the brief metadata-only ALTER TABLE operations.
The same pattern applies to table renames. Never RENAME TABLE in production. Instead, create a view with the old name that points to the new table, migrate code to use the new name, then drop the view.
lock_timeout — Your Safety Net
Every migration that touches a large table should set lock_timeout to prevent queue accumulation:
The queuing problem in detail: Without lock_timeout, a migration waiting for a lock will queue behind existing transactions. PostgreSQL's lock queue is FIFO — every subsequent query that tries to touch that table also queues behind the migration. A migration waiting 30 seconds for a lock can accumulate hundreds of queued connections, exhausting max_connections and bringing down the application. The migration itself may not even be blocking — it's the queue it creates.
With lock_timeout = '2s', the migration fails rather than queue. You retry during a quieter window. The application stays up.
In migration tools, enforce this as a connection-level default:
statement_timeout is your backup: if the migration itself runs longer than expected (a table rewrite on a table you didn't know was 200GB), it kills the migration rather than holding locks indefinitely.
Column Type Changes Without Table Rewrites
ALTER COLUMN TYPE rewrites the entire table. For large tables, this is an hours-long AccessExclusiveLock. The workaround: new column, backfill, swap.
Pattern: New column + backfill + swap
The rename transaction holds AccessExclusiveLock for both renames, but since they're metadata-only operations on the catalog, the lock is held for milliseconds — not proportional to table size.
Pattern: varchar length increases are free
Increasing varchar(N) length in PostgreSQL is a metadata-only operation — no table rewrite:
Decreasing length requires a constraint check (full table scan) — use the NOT VALID pattern. Changing to a fundamentally different type (e.g., integer to text) always rewrites the table — use the new column + backfill pattern.
Migration Tool Configuration for Safety
Enforce safe practices at the tooling level so individual developers don't have to remember.
For CI/CD pipelines, test migrations against a production-sized copy of the database before applying to production. pg_dump --schema-only + restore + pg_dump --data-only of a sample is often sufficient to catch "this alter will rewrite the table" surprises.
Detecting Lock Waits During Migrations
While a migration is running, monitor pg_stat_activity in a separate session to catch lock pile-ups early:
If you see this filling up during a migration, the migration is holding a lock that other queries are queuing behind. Kill the migration and investigate before retrying.
The Migration Checklist
Before running any migration on a production table with more than 1 million rows:
The 3am call you're trying to avoid is the one where the migration ran fine in staging (10k rows), then blocked production (500M rows) for 40 minutes while the on-call engineer frantically looked for the pg_cancel_backend command. This checklist exists because that call happens.
Summary
| Pattern | When to use | Lock held |
|---|---|---|
CREATE INDEX CONCURRENTLY | Adding any index to a live table | ShareUpdateExclusiveLock (non-blocking) |
ADD COLUMN ... DEFAULT constant (PG11+) | Adding column with a fixed default | AccessExclusiveLock, milliseconds |
| Add nullable + backfill + SET NOT NULL | Adding column with volatile default | Metadata locks only; backfill is row-level |
NOT VALID + VALIDATE CONSTRAINT | Adding FK or check constraint | Phase 1: instant; Phase 2: ShareUpdateExclusiveLock |
| Expand/contract (new col + backfill + rename + drop) | Renaming columns or tables | Metadata locks only |
| New col + backfill + swap | Changing column type | Metadata locks only |
lock_timeout | Every migration touching large tables | Prevents queue accumulation |
Zero-downtime migrations are not about clever hacks — they are about understanding exactly what PostgreSQL locks when, and structuring your changes to never hold an exclusive lock longer than milliseconds.
Next: Module 17 — Logical Replication and CDC Pipelines →
A CREATE INDEX CONCURRENTLY operation fails due to a unique constraint violation discovered during its second pass on a high-traffic table. What is the most critical immediate consequence for the application's performance and how should it be addressed?
A Senior DBA needs to add a new 'created_at' column with a NOT NULL DEFAULT NOW() constraint to a 500-million-row 'orders' table in a PostgreSQL 12 database. Which of the following approaches is the most appropriate for a zero-downtime migration, and why?
During a critical production database migration, a developer forgets to set lock_timeout. An ALTER TABLE operation attempts to acquire an AccessExclusiveLock on a heavily trafficked table but is blocked by a long-running transaction. What is the most likely and severe consequence of this scenario, and how does lock_timeout mitigate it?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
Sign in & RegisterDiscussion
0Join the discussion