Skip to main content
RunBook Academy

PostgreSQLIX · Locks, Blocking and DeadlocksLocks

DDL and the lock queue

Advanced⏱ ~35 minpsql

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

Not yet marked complete on this device.

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 SELECT that takes twenty seconds. It holds ACCESS SHARE.
  • B runs ALTER TABLE … ADD COLUMN. It wants ACCESS EXCLUSIVE, which conflicts with A, so it waits.
  • C runs a plain SELECT count(*). It wants ACCESS SHARE — which is compatible with the only granted lock on the table.
Service impact possiblethe queue, observed from a fourth session
$ 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)
Service impact possiblewhat happened to each
$ 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 ofDo
CREATE INDEXCREATE INDEX CONCURRENTLY
ADD CONSTRAINTADD CONSTRAINT … NOT VALID, then VALIDATE CONSTRAINT
SET NOT NULL directlyAdd a CHECK (col IS NOT NULL) NOT VALID, validate it, then SET NOT NULL
ALTER COLUMN TYPEAdd a new column, backfill in batches, swap
DROP COLUMN on a hot tableSchedule it; it is brief but still ACCESS EXCLUSIVE
REINDEXREINDEX 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 SELECT refused after 6 seconds while the only granted lock was another plain SELECT.
  • The outage duration is set by whatever was already running, not by the DDL.
  • SET lock_timeout before 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 VALID then VALIDATE.

Cross-course references

  • Git, CI/CD & GitOps — Part LVII (Approval gates) covers requiring a stated lock and a stated lock_timeout before 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

  1. Q1. A plain SELECT is refused with a lock timeout on a table whose only granted lock is another plain SELECT. What is happening?

  2. 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?

  3. 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?

  4. Q4. Which practices reduce the risk of a DDL statement causing a queue-driven outage? Select all that apply.

  5. Q5. Two transactions that each hold ACCESS SHARE on a table and then each attempt to upgrade to ACCESS EXCLUSIVE will deadlock.

  6. 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.