Table locks & DDL
Row locks are what you fight over in application code. Table locks are what take your site down during a deploy. Every statement — even a plain SELECT — locks every table it touches; the modes usually don't conflict, so you never feel it. Then a migration shows up.
A SELECT takes ACCESS SHARE, the weakest table lock. DROP TABLE, TRUNCATE, and most forms of ALTER TABLE take ACCESS EXCLUSIVE — the manual's rule for ALTER TABLE is that "An ACCESS EXCLUSIVE lock is acquired unless explicitly noted" — and ACCESS EXCLUSIVEconflicts with all modes, ACCESS SHARE included. So the strongest table lock and the weakest one can't coexist, which is the entire story of a DDL outage compressed into one sentence.
On its own that's fine: the ALTER waits for running queries to finish, then does its (often fast) work. The disaster needs one more ingredient — the lock queue.
The outage, reproduced
Picture three sessions. A long-lived transaction has already read the table and is sitting there holding ACCESS SHARE. A migration asks for ACCESS EXCLUSIVE and can't have it, so it waits. And here's the twist: once that migration is waiting, every plain SELECT that arrives after it has to queue too.
A is any long-lived transaction that has touched the table — a report, a stuck job…
A> BEGIN;
BEGIN
A> SELECT balance FROM accounts WHERE id = 1;
balance
---------
100
(1 row)The migration needs ACCESS EXCLUSIVE, so it waits for A. Expected. But now —
B> ALTER TABLE accounts ADD COLUMN note text;
⏳ B is waiting for a lock…— every new query on the table queues behind the waiting ALTER. This is the outage.
C> SELECT balance FROM accounts WHERE id = 1;
⏳ C is waiting for a lock…
M> SELECT waiter.application_name AS waiter, blocker.application_name AS blocker
FROM pg_stat_activity waiter
JOIN pg_stat_activity blocker ON blocker.pid = ANY (pg_blocking_pids(waiter.pid))
WHERE waiter.wait_event_type = 'Lock'
ORDER BY waiter.application_name, blocker.application_name;
waiter | blocker
--------+---------
B | A
C | B
(2 rows)Only when A ends does the pile-up drain — migration first, then the reads.
A> COMMIT;
COMMIT
⏵ B resumes:
ALTER TABLE
⏵ C resumes:
balance
---------
100
(1 row)Verified against PostgreSQL 18.4 · Run it yourself · Scenario source
The cruel part: the ALTER itself is instant. What kills you is a waiting ALTER, because every later query — reads included — must queue behind its ACCESS EXCLUSIVE request. One forgotten BEGIN in a console session plus one routine migration equals a full table outage.
Never run DDL without lock_timeout
A migration that fails fast and retries is a non-event; a migration that queues is an outage.
The fix: lock_timeout + retry
A> BEGIN;
BEGIN
A> SELECT balance FROM accounts WHERE id = 1; -- the same long transaction as before
balance
---------
100
(1 row)Same migration — but this time it gives up after 100ms instead of camping in the queue.
B> SET lock_timeout = '100ms';
SET
B> ALTER TABLE accounts ADD COLUMN note text;
ERROR: 55P03: canceling statement due to lock timeoutNo waiting ALTER in the queue means no outage: C's read is instant.
C> SELECT balance FROM accounts WHERE id = 1;
balance
---------
100
(1 row)
A> COMMIT;
COMMITRetry the migration when it can actually get the lock — now it sails through.
B> ALTER TABLE accounts ADD COLUMN note text;
ALTER TABLEVerified against PostgreSQL 18.4 · Run it yourself · Scenario source
Set lock_timeout once in your migration tool, per session rather than in postgresql.conf — the manual advises against setting it globally because it would trip every session, not only your migrations. A migration that can't get its lock now fails fast and retries instead of parking a ACCESS EXCLUSIVE request at the head of the queue.
That leaves one number to respect: the blast radius. It's the longest transaction currently touching the table multiplied by all the traffic on that table, which is why long-running transactions and migrations are natural enemies. It also helps to know that not every ALTER TABLE is equally dangerous — the manual lists the lock each form takes, and some are gentle. SET STATISTICS needs only SHARE UPDATE EXCLUSIVE, which blocks neither reads nor writes. A plain CREATE INDEXlocks out writes but not reads; CREATE INDEX CONCURRENTLY exists to avoid even that. When you do want to watch a pile-up form live, the wait-chain query from the transcript is unpacked in the next lesson on monitoring locks.
Further reading
- PostgreSQL docs: Table-Level Locks — all eight modes and their conflict table
- PostgreSQL docs: ALTER TABLE
- The same lesson on MySQL