PostgreSQLIX · Locks, Blocking and DeadlocksLocks
DDL and the lock queue
What you'll learn
- Explain why a waiting strong lock blocks compatible requests behind it
- Write a migration that cannot hold a strong lock for long
- Choose the split form of a DDL operation where one exists
- Set lock_timeout and a retry loop as standard practice for DDL
Prerequisites
Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27
This is the lesson that explains why “we only added a column” and “the site was down for four minutes” are both true statements about the same event.
The measurement
Three sessions against one table, started two seconds apart.
- A runs a
SELECTthat takes twenty seconds. It holdsACCESS SHARE. - B runs
ALTER TABLE … ADD COLUMN. It wantsACCESS EXCLUSIVE, which conflicts with A, so it waits. - C runs a plain
SELECT count(*). It wantsACCESS SHARE— which is compatible with the only granted lock on the table.
$ psql -U postgres -c "SELECT a.pid, a.state, l.mode, l.granted, left(a.query,42) AS query, pg_blocking_pids(a.pid) AS blocked_by FROM pg_locks l JOIN pg_stat_activity a ON a.pid=l.pid JOIN pg_class c ON c.oid=l.relation WHERE c.relname='lockdemo' ORDER BY a.pid" pid | state | mode | granted | query | blocked_by
------+--------+---------------------+---------+--------------------------------------------+------------
1615 | active | AccessShareLock | t | SELECT pg_sleep(20); | {}
1622 | active | AccessExclusiveLock | f | ALTER TABLE lockdemo ADD COLUMN note text; | {1615}
1629 | active | AccessShareLock | f | SELECT count(*) FROM lockdemo; | {1622}
(3 rows)$ psql -U postgres -c "SET lock_timeout='6s'; SELECT count(*) FROM lockdemo"-- session C, an ordinary read:
ERROR: canceling statement due to lock timeout
Time: 6000.623 ms (00:06.001)
-- session B, the migration:
ALTER TABLE
Time: 18023.558 ms (00:18.024)Session C wanted a lock that conflicts with nothing currently held, and it was refused.
Why
PostgreSQL grants locks in request order. When a request arrives it is placed at the back of the queue, and it is granted only when it conflicts with nothing ahead of it — held or waiting.
Session C conflicts with B’s pending ACCESS EXCLUSIVE request, which
sits ahead of it. So C waits, even though B holds nothing.
The alternative — granting compatible requests immediately — would mean
a stream of SELECTs could starve an ALTER TABLE indefinitely. The
ordered queue is the correct design. It is also why one waiting strong
lock stops a table completely.
Writing migrations that cannot do this
Always set lock_timeout
SET lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN note text;
If the lock is not available within three seconds, the statement fails
with ERROR: canceling statement due to lock timeout and the queue it
was building dissolves immediately. Failing fast is strictly better than
waiting, because the cost of waiting is borne by everyone else.
Then retry, from the shell rather than from inside a transaction:
for attempt in 1 2 3 4 5; do
if psql -v ON_ERROR_STOP=1 -c "SET lock_timeout='3s'; ALTER TABLE orders ADD COLUMN note text;"; then
echo "applied on attempt ${attempt}"
break
fi
echo "attempt ${attempt} could not acquire the lock; retrying"
sleep 10
done
Prefer the split form
Several operations have a form that separates the brief strong-lock part from the long-running part.
| Instead of | Do |
|---|---|
CREATE INDEX | CREATE INDEX CONCURRENTLY |
ADD CONSTRAINT | ADD CONSTRAINT … NOT VALID, then VALIDATE CONSTRAINT |
SET NOT NULL directly | Add a CHECK (col IS NOT NULL) NOT VALID, validate it, then SET NOT NULL |
ALTER COLUMN TYPE | Add a new column, backfill in batches, swap |
DROP COLUMN on a hot table | Schedule it; it is brief but still ACCESS EXCLUSIVE |
REINDEX | REINDEX CONCURRENTLY |
The SET NOT NULL row deserves the detail, because it is the one people
get wrong most often. Setting it directly takes ACCESS EXCLUSIVE and
holds it for a full table scan. The three-step version takes the strong
lock only briefly each time:
-- 1. brief ACCESS EXCLUSIVE, no scan
ALTER TABLE orders ADD CONSTRAINT orders_note_nn CHECK (note IS NOT NULL) NOT VALID;
-- 2. SHARE UPDATE EXCLUSIVE, scans the table, blocks nothing
ALTER TABLE orders VALIDATE CONSTRAINT orders_note_nn;
-- 3. brief ACCESS EXCLUSIVE; PostgreSQL uses the validated constraint
-- as proof and skips the scan
ALTER TABLE orders ALTER COLUMN note SET NOT NULL;
-- 4. the CHECK is now redundant
ALTER TABLE orders DROP CONSTRAINT orders_note_nn;
Clear the table before you touch it
-- anything old enough to make a strong lock wait
SELECT pid, state, now() - xact_start AS xact_age, left(query, 60) AS query
FROM pg_stat_activity
WHERE datname = current_database()
AND state <> 'idle'
AND now() - xact_start > interval '30 seconds'
ORDER BY xact_start;
If anything is there, deal with it before the DDL rather than after the queue has formed.
What to take from this
- Locks are granted in request order. A waiting strong request blocks every later request, including compatible ones.
- Measured: a plain
SELECTrefused after 6 seconds while the only granted lock was another plainSELECT. - The outage duration is set by whatever was already running, not by the DDL.
SET lock_timeoutbefore every DDL statement, and retry from outside the transaction.- Multi-table migrations in one transaction hold the first table’s lock for the whole migration.
- Prefer split forms:
CONCURRENTLY,NOT VALIDthenVALIDATE.
Cross-course references
- Git, CI/CD & GitOps — Part LVII (Approval gates) covers requiring
a stated lock and a stated
lock_timeoutbefore a migration merges, and Part LIX (Rollback) covers what rolling back a partially applied migration actually means. - Ansible for Production Sysadmins — Part XLVIII (Maintenance windows and rollback) covers running the same statement across an estate with a bounded blast radius.
Quiz
Knowledge check · 6 questions
Q1. A plain SELECT is refused with a lock timeout on a table whose only granted lock is another plain SELECT. What is happening?
Q2. A migration wraps six ALTER TABLE statements against six tables in one transaction, each with lock_timeout set to 3s. What is the exposure?
Q3. A CREATE INDEX CONCURRENTLY has been running for hours on a small table and appears hung, while blocking nothing. What is the most likely cause?
Q4. Which practices reduce the risk of a DDL statement causing a queue-driven outage? Select all that apply.
Q5. Two transactions that each hold ACCESS SHARE on a table and then each attempt to upgrade to ACCESS EXCLUSIVE will deadlock.
Q6. Write the sequence you would use to add a NOT NULL constraint to a large, busy table, and say what each step locks.
Passing score: 75%. Answers are checked in this browser.