Visualizing Lock Chains: Find the Root Blocker in Seconds

Visualizing Lock Chains: Find the Root Blocker in Seconds

The Problem

Session A is blocked. You check pg_locks and find Session B holds the conflicting lock. You terminate Session B, but Session A is still blocked. That is because Session B was also blocked — by Session C. Session C acquired an AccessExclusiveLock because someone ran ALTER TABLE on a high-traffic table without thinking about the consequences. A flat list of pg_locks does not show you this dependency chain.

Lock chains form when sessions queue behind a blocker, and that blocker is itself waiting on another lock. The chain can be three, four, or more levels deep. Each additional level multiplies the blast radius — every new session that touches any locked relation joins the chain. Within minutes, a single forgotten ALTER TABLE in a production psql session can block dozens of connections, exhaust your connection pool, and bring the application to a standstill.

The standard response under pressure is to scan pg_stat_activity, find something that looks stuck, and terminate it. If you kill an intermediate blocker instead of the root, nothing improves. The intermediate session goes away, but the root blocker still holds its lock, and every other session remains blocked. You have wasted time and confused your incident timeline. What you need is visibility into the full chain — from leaf waiters back to the single session holding the root lock.

How to Detect It

PostgreSQL provides the raw data in pg_locks and pg_stat_activity, but assembling it into a chain requires a recursive CTE:

-- Build lock dependency chains from pg_locks + pg_stat_activity
WITH RECURSIVE lock_chain AS (
    SELECT
        blocked.pid AS blocked_pid,
        blocking.pid AS blocking_pid,
        blocked.locktype,
        blocked.relation::regclass AS locked_relation,
        act.query AS blocked_query,
        act.wait_event_type,
        act.wait_event,
        1 AS depth
    FROM pg_locks blocked
    JOIN pg_locks blocking
        ON blocking.locktype = blocked.locktype
        AND blocking.database IS NOT DISTINCT FROM blocked.database
        AND blocking.relation IS NOT DISTINCT FROM blocked.relation
        AND blocking.page IS NOT DISTINCT FROM blocked.page
        AND blocking.tuple IS NOT DISTINCT FROM blocked.tuple
        AND blocking.virtualxid IS NOT DISTINCT FROM blocked.virtualxid
        AND blocking.transactionid IS NOT DISTINCT FROM blocked.transactionid
        AND blocking.pid != blocked.pid
        AND blocking.granted
        AND NOT blocked.granted
    JOIN pg_stat_activity act ON act.pid = blocked.pid

    UNION ALL

    SELECT
        lc.blocked_pid,
        b2.pid AS blocking_pid,
        b2.locktype,
        b2.relation::regclass,
        lc.blocked_query,
        lc.wait_event_type,
        lc.wait_event,
        lc.depth + 1
    FROM lock_chain lc
    JOIN pg_locks w ON w.pid = lc.blocking_pid AND NOT w.granted
    JOIN pg_locks b2
        ON b2.locktype = w.locktype
        AND b2.database IS NOT DISTINCT FROM w.database
        AND b2.relation IS NOT DISTINCT FROM w.relation
        AND b2.pid != w.pid
        AND b2.granted
)
SELECT * FROM lock_chain ORDER BY depth DESC;

This works, but it is complex, easy to get wrong under pressure, and produces a flat result set that still requires mental effort to visualize as a tree. During an incident at 3 AM, you do not want to be debugging a recursive CTE — you want to see the chain and act on it.

How myDBA.dev Shows It

Lock chain dependency graph showing root blocker PID highlighted in red, intermediate blockers in orange, and waiting sessions in grey, with arrows showing the blocking direction

myDBA.dev renders lock chains as a visual dependency graph. The root blocker is highlighted at the top of the tree, with arrows flowing down to every session it is directly or indirectly blocking. You do not need to trace through query results or mentally reconstruct the chain — the graph shows the entire structure at a glance. One look tells you which single session to terminate to unblock the entire chain.

Lock chain detail panel showing lock type, relation name, query text, and transaction duration for each node in the chain

Each node in the graph displays the lock type (AccessExclusiveLock, RowExclusiveLock), the locked relation, the query text running in each session, and how long the transaction has been open. The detail panel distinguishes root blockers from intermediate waiters, so you never accidentally terminate a session that is itself a victim. The transaction duration on the root blocker tells you whether this is a long-running DDL operation, an idle-in-transaction session, or an active query — each requires a different response.

How to Fix It

Terminate the root blocker, not intermediate nodes:

-- Terminate the root blocking session (get the PID from the chain graph)
SELECT pg_terminate_backend(12345);

Use pg_terminate_backend rather than pg_cancel_backend. Cancel sends a query-level interrupt and only works if the session is actively executing a query. A session that is idle in transaction — the most common root blocker — ignores cancel signals. Terminate closes the entire connection, releasing all held locks immediately.

If the root blocker is a DDL operation like ALTER TABLE that you cannot simply kill, set a lock_timeout on the DDL session so it gives up instead of blocking indefinitely:

-- Set lock_timeout before running DDL
SET lock_timeout = '5s';
ALTER TABLE orders ADD COLUMN status_code integer;
RESET lock_timeout;

With lock_timeout = 5s, the ALTER TABLE fails with an error if it cannot acquire the lock within 5 seconds, instead of blocking every other session until it eventually gets the lock. You can then retry the DDL during a lower-traffic window.

For index operations, always use CONCURRENTLY:

-- Non-blocking index creation
CREATE INDEX CONCURRENTLY idx_orders_status
    ON orders (status_code);

CREATE INDEX CONCURRENTLY takes a weaker ShareUpdateExclusiveLock instead of the default ShareLock, allowing concurrent inserts, updates, and deletes to continue while the index is built.

How to Prevent It

Set lock_timeout as a session default for all application connections. A value of 3-5 seconds prevents any single query from blocking other sessions for longer than that:

ALTER SYSTEM SET lock_timeout = '5s';
SELECT pg_reload_conf();

Set statement_timeout as a safety net to kill queries that run unexpectedly long. Never run DDL statements — ALTER TABLE, DROP INDEX, CREATE INDEX (without CONCURRENTLY) — during peak traffic hours. Schedule schema changes for maintenance windows or use tools like pg_repack and CREATE INDEX CONCURRENTLY that minimize lock contention.

Monitor lock wait events continuously. A spike in Lock wait events indicates a chain is forming. myDBA.dev surfaces lock chains in real time, showing the full dependency graph the moment blocking begins — before the chain grows long enough to exhaust your connection pool. Catching a two-node chain early is a non-event; catching a twenty-node chain at 3 AM is an incident.