Skip to main content
RunBook Academy

PostgreSQLIX · Locks, Blocking and DeadlocksLocks

Finding the blocker

Intermediate⏱ ~30 min🧪 Lab requiredpsql

What you'll learn

  • Produce the blocking chain for a live incident in one query
  • Follow a chain to its root rather than acting on the first waiter found
  • Configure the logging that reconstructs a blocking incident afterwards
  • Recognise the cases where the root blocker holds no lock at all

Prerequisites

Practice

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.

At three in the morning the question is never “what lock modes conflict”. It is “which session do I need to deal with”, and getting that wrong costs another twenty minutes.

The one query

pg_blocking_pids(pid) returns the array of process ids blocking a given backend. It handles the cases a hand-written pg_locks self-join gets wrong, and it is the right foundation.

SELECT a.pid,
       a.usename,
       a.application_name,
       a.state,
       now() - a.xact_start          AS xact_duration,
       now() - a.state_change        AS in_state_for,
       a.wait_event_type,
       a.wait_event,
       pg_blocking_pids(a.pid)       AS blocked_by,
       left(a.query, 80)             AS query
  FROM pg_stat_activity a
 WHERE a.backend_type = 'client backend'
   AND (cardinality(pg_blocking_pids(a.pid)) > 0
        OR a.pid = ANY (SELECT unnest(pg_blocking_pids(b.pid))
                          FROM pg_stat_activity b))
 ORDER BY cardinality(pg_blocking_pids(a.pid)), a.xact_start;

That returns everyone involved: the waiters and the sessions they are waiting on. Ordering by the number of blockers puts the root — which waits on nobody — at the top.

Why the first pid you find is usually the wrong one

Read-only / Safea three-session chain, measured
$ 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)

The application reports that SELECT count(*) is hanging. Investigating that session, pid 1629, shows it blocked by 1622 — the ALTER TABLE.

But 1622 holds no lock. Its granted column is false. Terminating it frees 1629, and the migration has failed for no reason while pid 1615 carries on and blocks the next attempt.

The session that must be dealt with is 1615, which appears nowhere in 1629’s blocked_by. Chains have to be walked.

Walking the chain to the root

WITH RECURSIVE chain AS (
    SELECT a.pid,
           pg_blocking_pids(a.pid) AS blockers,
           1                        AS depth,
           ARRAY[a.pid]             AS path
      FROM pg_stat_activity a
     WHERE cardinality(pg_blocking_pids(a.pid)) > 0

    UNION ALL

    SELECT b.pid,
           pg_blocking_pids(b.pid),
           c.depth + 1,
           c.path || b.pid
      FROM chain c
      CROSS JOIN LATERAL unnest(c.blockers) AS blocker(pid)
      JOIN pg_stat_activity b ON b.pid = blocker.pid
     WHERE NOT b.pid = ANY (c.path)
       AND c.depth < 20
)
SELECT DISTINCT ON (chain.pid)
       chain.pid,
       chain.depth,
       cardinality(chain.blockers) AS blocker_count,
       a.state,
       now() - a.xact_start        AS xact_duration,
       a.wait_event_type, a.wait_event,
       left(a.query, 70)           AS query
  FROM chain
  JOIN pg_stat_activity a ON a.pid = chain.pid
 ORDER BY chain.pid, chain.depth DESC;

The rows with blocker_count = 0 are the roots. Those are the sessions worth acting on.

Reading the root

Once you have the root, pg_stat_activity tells you what kind of problem you have.

stateMeaningTypical action
active, recent xact_startDoing real workWait, or cancel if it is a runaway
active, very old xact_startLong-running queryDecide deliberately; lesson IX-06
idle in transactionClient walked awayTerminate. It is not doing anything
idle in transaction (aborted)Errored, client has not rolled backTerminate. Holds nothing but a slot
idleNot in a transactionIt is not the blocker. Look again

wait_event_type and wait_event say what the root itself is waiting on, and a root that is waiting on IO or Client is a different problem from one that is computing.

Cases where the root holds no lock

Three situations where the chain leads somewhere unexpected.

The root is idle in transaction. It holds locks from statements that have already finished. pg_stat_activity.query shows its last statement, not what it is doing, and reading that query as the cause is a standard misdiagnosis.

The root is waiting on something that is not a lock. A session blocked on IO, or on Client while the application fails to read its results, still holds everything it has acquired. wait_event_type says which.

The blocker is not a client backend at all. An autovacuum worker holds SHARE UPDATE EXCLUSIVE and blocks DDL; an anti-wraparound worker does not yield, as lesson VIII-05 covers. Filtering pg_stat_activity to backend_type = 'client backend' hides these, which is why the query above applies that filter only to the waiters.

What to take from this

  • pg_blocking_pids() is the right primitive. Walk it recursively to find roots.
  • Include a cycle guard and a depth limit. Deadlocks make an unguarded recursive query hang.
  • The first blocker in a chain is often itself waiting. Act on the root, not on the nearest name.
  • log_lock_waits = on costs nothing on a healthy system and is the only way to reconstruct an incident afterwards.
  • Alert on wait_event_type = 'Lock'; investigate with pg_blocking_pids.

Cross-course references

  • Linux for Production Sysadmins — Part LXXIX (Troubleshooting methodology) covers following a dependency chain to its head rather than acting on the loudest symptom, and Part LXXXI (Incident command) covers who authorises terminating the session at the head of it.
  • Observability for Production Sysadmins — Part CIX (Incident investigation workflows) covers capturing the blocking tree before clearing it, because it does not exist afterwards.

Quiz

Knowledge check · 6 questions

  1. Q1. An application reports a hanging SELECT. pg_blocking_pids on that session returns a single pid, which turns out to be an ALTER TABLE whose own lock is not granted. What should be terminated?

  2. Q2. Why must a recursive blocking-chain query include a guard against revisiting pids already in the path?

  3. Q3. A monitoring dashboard calls pg_blocking_pids for every row of pg_stat_activity every five seconds on a cluster with a thousand connections. What is the concern?

  4. Q4. Which situations can leave a blocking chain whose root holds locks but is not executing anything? Select all that apply.

  5. Q5. log_lock_waits is on by default, so a blocking incident can normally be reconstructed from the server log without any prior configuration.

  6. Q6. You are paged for widespread query timeouts on one table. Describe your first three steps.

Passing score: 75%. Answers are checked in this browser.