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.

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 ... (PG11+)AccessExclusiveLockYesYesBrief
ADD COLUMN ... NOT NULL (no default)AccessExclusiveLockYesYesNo — rewrites table
ALTER COLUMN TYPEAccessExclusiveLockYesYesNo — rewrites table
ADD CONSTRAINT ... NOT VALIDShareUpdateExclusiveLockNoNoYes
VALIDATE CONSTRAINTShareUpdateExclusiveLockNoNoYes
DROP COLUMNAccessExclusiveLockYesYesBrief (mark only)
RENAME COLUMNAccessExclusiveLockYesYesNo
RENAME TABLEAccessExclusiveLockYesYesNo

"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:

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.

Phase 2: Validate separately — VALIDATE CONSTRAINT checks existing rows using ShareUpdateExclusiveLock, which allows concurrent reads and most writes.

sql

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:

sql

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)

sql

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)

javascript

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

sql

Run during off-peak hours. For large tables, batch as shown earlier.

Step 4: Add NOT NULL constraint (after backfill completes)

sql

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)

sql

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:

sql

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:

sql
yaml

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

sql

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:

sql

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.

yaml
typescript
javascript

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:

sql
sql

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:

text

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

PatternWhen to useLock held
CREATE INDEX CONCURRENTLYAdding any index to a live tableShareUpdateExclusiveLock (non-blocking)
ADD COLUMN ... DEFAULT constant (PG11+)Adding column with a fixed defaultAccessExclusiveLock, milliseconds
Add nullable + backfill + SET NOT NULLAdding column with volatile defaultMetadata locks only; backfill is row-level
NOT VALID + VALIDATE CONSTRAINTAdding FK or check constraintPhase 1: instant; Phase 2: ShareUpdateExclusiveLock
Expand/contract (new col + backfill + rename + drop)Renaming columns or tablesMetadata locks only
New col + backfill + swapChanging column typeMetadata locks only
lock_timeoutEvery migration touching large tablesPrevents 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 →


Knowledge Check

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

Discussion

0

Join the discussion

Loading comments...

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