Reported symptoms
At 14:06 every request touching the customers table starts timing out.
The error rate on those endpoints reaches 100% in under twenty seconds.
Endpoints that do not touch customers are fine, which initially points
everybody at one application service.
The database looks healthy: normal CPU, normal I/O, no connection spike
beyond the queries now piling up. pg_stat_activity shows forty-one
sessions active with wait_event_type = Lock, all of them ordinary
SELECT statements against customers.
The team terminates the oldest waiting SELECT. Another takes its place
immediately.
A schema migration is running in a separate pipeline. Its ALTER TABLE
has been executing for four minutes with no output. At 14:19 somebody
terminates it, the queue drains in under a second, and the application
recovers before anyone has agreed on what happened.
Evidence provided
$ psql -c "SELECT pid, mode, granted FROM pg_locks WHERE relation='customers'::regclass AND locktype='relation' ORDER BY granted DESC, pid;" pid | mode | granted
-------+---------------------+---------
22104 | AccessShareLock | t
22887 | AccessExclusiveLock | f
22903 | AccessShareLock | f
22904 | AccessShareLock | f
22906 | AccessShareLock | f
... 36 further AccessShareLock rows, all granted = fIllustrative output
$ psql -c "SELECT pid, pg_blocking_pids(pid) AS blocked_by, left(query,40) AS query FROM pg_stat_activity WHERE cardinality(pg_blocking_pids(pid))>0 ORDER BY pid;" pid | blocked_by | query
-------+------------+------------------------------------------
22887 | {22104} | ALTER TABLE customers ADD COLUMN loyalt
22903 | {22887} | SELECT id, email FROM customers WHERE i
22904 | {22887} | SELECT count(*) FROM customers WHERE cr
22906 | {22887} | SELECT id, email FROM customers WHERE i
... 37 further rows, all blocked_by {22887}Illustrative output
Session 22104 is idle in transaction, having completed a reporting
SELECT at 14:02:41 and never committed. The customers table took 14
writes in the preceding hour.
log_lock_waits was off. After it was enabled during the incident:
$ grep -E 'still waiting|Wait queue' /var/log/postgresql/postgresql-18-main.log | tail -32026-08-28 14:19:02.118 UTC [22887] LOG: process 22887 still waiting for AccessExclusiveLock on relation 41209 of database 16401 after 1000.114 ms
2026-08-28 14:19:02.118 UTC [22887] DETAIL: Process holding the lock: 22104. Wait queue: 22887.
2026-08-28 14:19:02.118 UTC [22887] STATEMENT: ALTER TABLE customers ADD COLUMN loyalty_tier text;Illustrative output
Work the evidence before reading on
- Forty-one
SELECTstatements are blocked.SELECTdoes not conflict withSELECT. What is blocking them? pg_blocking_pidssays the readers are blocked by 22887, not by 22104. Which one do you act on?- The table took 14 writes in an hour. Does that make a lock incident more or less likely, or neither?
- Terminating the oldest waiting
SELECTdid nothing. Why was that predictable?
Root cause
The queue, not the lock
AccessShareLock — what a plain SELECT takes — does not conflict with
AccessShareLock. Session 22903 could have been granted its lock
immediately alongside 22104. It was not.
The lock manager does not allow a request to jump ahead of an
incompatible request that is already waiting. Session 22887 asked for
AccessExclusiveLock first. Everything arriving afterwards queues behind
it, whether or not it conflicts with what is currently held.
The victims are not the cause
pg_blocking_pids reports the readers as blocked by 22887, the
migration, not by 22104, the reporting session. That is accurate: the
proximate blocker is the migration.
But the root of the chain is 22104, and it is the only session that is blocking somebody while being blocked by nobody. That pair of conditions is what identifies it:
SELECT a.pid, a.usename, a.state, now() - a.xact_start AS xact_age,
left(a.query, 60) AS query
FROM pg_stat_activity a
WHERE cardinality(pg_blocking_pids(a.pid)) = 0
AND EXISTS (SELECT 1 FROM pg_stat_activity b
WHERE a.pid = ANY(pg_blocking_pids(b.pid)))
ORDER BY xact_age DESC;
On this incident that returns one row out of forty-two blocked sessions, and it is a session nobody had reported, running nothing, that had finished its work four minutes earlier.
Two things made it worse than necessary
The migration ran with no lock_timeout. It was therefore prepared to
hold the head of that queue for as long as the reporting transaction
lasted, which had no upper bound.
And log_lock_waits was off, so for the first thirteen minutes the
server’s own commentary — which names the holder, lists the queue in
order, and quotes the statement — did not exist.
Resolution
Restore service first. Terminating the migration releases the forty readers immediately and costs only a deployment that can be retried:
SELECT pg_terminate_backend(22887);
Terminating the reporting session instead lets the migration proceed,
complete, and release everything. That is right only when you know the
migration is fast — and ADD COLUMN with no default is, in PostgreSQL
11 and later, a catalog-only change that takes milliseconds once it has
its lock.
Either is defensible. Terminating individual waiting readers is not.
Then fix the two contributing conditions:
ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();
And re-run the migration properly:
SET lock_timeout = '3s';
ALTER TABLE customers ADD COLUMN loyalty_tier text;
Verification
SELECT count(*) FROM pg_stat_activity WHERE wait_event_type = 'Lock'
returns zero.
pg_locks for customers has no granted = f rows.
Application error rates return to baseline and latency returns to its normal band.
The re-run migration either completes in milliseconds or fails fast with
canceling statement due to lock timeout, and in the failing case no
queue forms.
SHOW log_lock_waits returns on, and a deliberately created wait
produces the still waiting for and Wait queue: lines.
Prevention
lock_timeout on every statement that takes AccessExclusiveLock,
enforced in the migration tooling. This is the single change that
prevents the class, and enforcing it centrally means it does not depend
on each migration’s author.
log_lock_waits = on. One log line per wait longer than
deadlock_timeout, and it is the only evidence that survives the
sessions ending.
idle_in_transaction_session_timeout. The reporting session had been
idle in transaction for four minutes before the migration arrived. A
timeout in the minutes removes the holder before there is anything to
hold up.
Alert on lock waits, not on application errors. A session waiting on
wait_event_type = 'Lock' for more than a few seconds is detectable in
seconds; a 100% error rate is detectable after users have noticed.
Put the root-of-chain query in the runbook, because the natural instinct in a lock incident is to kill what is complaining, and that is exactly backwards.