PostgreSQL’s lock conflict matrix is the part everyone learns: SELECT takes ACCESS SHARE, ALTER TABLE takes ACCESS EXCLUSIVE, and those two conflict. Many readers coexist happily; a writer of the schema excludes everyone.
The part that causes outages is the queue.
Locks are granted in request order, and a new request that conflicts with an already-waiting request must wait behind it. It does not matter that the new request would be perfectly compatible with what is currently held:
session A ACCESS SHARE held — an idle transaction, holding, doing nothing
session B ACCESS EXCLUSIVE waiting — ALTER TABLE, blocked by A
session C ACCESS SHARE waiting — a plain SELECT, compatible with A,
blocked by B, which holds nothing at allSession C is stopped by a lock that has not been granted. Every subsequent reader joins the same queue, so one idle transaction plus one DDL statement takes a table completely offline — while the database itself does no work at all. CPU and I/O near zero is consistent with a total outage here, and that combination is otherwise so unusual that it is worth treating as a signature.
Seeing it
SELECT pid, state, wait_event_type, wait_event,
now() - xact_start AS xact_age, left(query, 60) AS query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY xact_start;
SELECT pg_blocking_pids(pid), pid, left(query, 60) FROM pg_stat_activity WHERE cardinality(pg_blocking_pids(pid)) > 0;The head of the queue is the interesting row, and it is usually not the one anyone is looking at: the DDL is the visible blocker, but the thing blocking the DDL is generally a session in idle in transaction that has been sitting there for a very long time doing nothing.
Bounding it
lock_timeout before every DDL statement. One line, and it converts the failure from an outage into a failed migration:
SET lock_timeout = '2s';
ALTER TABLE orders ADD COLUMN discount_cents integer;
-- ERROR: canceling statement due to lock timeoutA migration that fails is retried five minutes later at no cost. A migration that waits builds a queue behind it for as long as it waits — see a table that went offline behind a statement that never ran.
idle_in_transaction_session_timeout removes the usual blocker class outright, and is worth setting per role for anything interactive or analytical:
ALTER ROLE analytics SET idle_in_transaction_session_timeout = '60s';Neither setting makes the lock conflict go away. They decide whether an unlucky moment costs one migration or one table.