Postgres Lock Monitoring pg_locks

PostgreSQLBackendDatabase
Share on LinkedIn Share on X Share on Reddit Share on HN Share on Bluesky

A single uncommitted transaction holding a row lock can stall an entire checkout pipeline. A migration grabbing AccessExclusiveLock during peak hours queues every query behind it. Postgres exposes lock state through pg_locks and session state through pg_stat_activity — but raw output is overwhelming without knowing which lock modes conflict, how to build a blocking tree, and when termination is justified.

This article provides diagnostic queries, lock mode reference, and remediation workflows used during real incidents.

Lock mode hierarchy

Postgres lock modes from least to most restrictive:

Mode Typical operations Blocks
AccessShareLock SELECT AccessExclusive only
RowShareLock SELECT FOR UPDATE/SHARE Exclusive, AccessExclusive
RowExclusiveLock INSERT, UPDATE, DELETE Share, ShareRowExclusive, Exclusive, AccessExclusive
ShareUpdateExclusiveLock VACUUM, CREATE INDEX CONCURRENTLY ShareUpdateExclusive, Share, ShareRowExclusive, Exclusive, AccessExclusive
ShareLock CREATE INDEX (non-concurrent) RowExclusive and above
ShareRowExclusiveLock -- rare RowExclusive and above
ExclusiveLock REFRESH MATERIALIZED VIEW CONCURRENTLY RowShare and above
AccessExclusiveLock ALTER TABLE, DROP, TRUNCUM FULL Everything

Most OLTP blocking involves RowExclusiveLock contention on hot rows or AccessExclusiveLock from DDL.

Core diagnostic query

Blocking sessions with queries (PG 14+):

SELECT
  blocked.pid          AS blocked_pid,
  blocked.usename,
  blocked.application_name,
  now() - blocked.query_start AS blocked_duration,
  left(blocked.query, 100)    AS blocked_query,
  blocking.pid         AS blocking_pid,
  blocking.usename     AS blocking_user,
  now() - blocking.query_start AS blocking_duration,
  left(blocking.query, 100)   AS blocking_query,
  blocked.wait_event_type,
  blocked.wait_event
FROM pg_stat_activity blocked
CROSS JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS blocking_pid
JOIN pg_stat_activity blocking ON blocking.pid = blocking_pid
WHERE blocked.wait_event_type = 'Lock'
ORDER BY blocked_duration DESC;

Pre-PG 14 alternative using pg_locks:

SELECT
  blocked_locks.pid     AS blocked_pid,
  blocked_activity.query AS blocked_query,
  blocking_locks.pid    AS blocking_pid,
  blocking_activity.query AS blocking_query,
  blocked_activity.wait_event_type
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity
  ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
  ON blocking_locks.locktype = blocked_locks.locktype
  AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
  AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
  AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
  AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
  AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
  AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
  AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
  AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
  AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
  AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity
  ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted
  AND blocking_locks.granted;

Understanding pg_locks columns

SELECT locktype, relation::regclass, mode, granted, pid, transactionid, virtualxid
FROM pg_locks
WHERE pid = 12345;
Column Meaning
locktype relation, tuple, transactionid, virtualxid, advisory
relation Table/index OID (locktype=relation)
mode Lock mode requested or held
granted true = held; false = waiting
transactionid Row-level lock via transaction ID
virtualxid Lock on virtual transaction ID

granted = false rows are waiting. Join to pg_stat_activity for the waiting query.

Lock wait chains

Multi-level blocking (A blocks B blocks C):

WITH RECURSIVE lock_chain AS (
  SELECT
    pid,
    pg_blocking_pids(pid) AS blocking_pids,
    1 AS depth
  FROM pg_stat_activity
  WHERE wait_event_type = 'Lock'

  UNION ALL

  SELECT
    lc.pid,
    pg_blocking_pids(blocker),
    lc.depth + 1
  FROM lock_chain lc
  CROSS JOIN unnest(lc.blocking_pids) AS blocker
  WHERE lc.depth < 10
)
SELECT DISTINCT depth, pid FROM lock_chain ORDER BY depth;

The root blocker at maximum depth is the session to investigate first.

Common blocking scenarios

Idle in transaction

SELECT pid, state, now() - xact_start AS xact_age, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start;

Application opened transaction, did SELECT, returned connection to pool without COMMIT. Holds RowShareLock or RowExclusiveLock indefinitely.

Fix: application bug — ensure COMMIT/ROLLBACK in finally blocks. Immediate: pg_terminate_backend(pid).

Prevention:

ALTER SYSTEM SET idle_in_transaction_session_timeout = '60s';

Hot row updates

Concurrent UPDATE on same row — second transaction waits for first to commit:

-- Session 1
BEGIN; UPDATE inventory SET qty = qty - 1 WHERE sku = 'ABC123';
-- (no commit)

-- Session 2
UPDATE inventory SET qty = qty - 1 WHERE sku = 'ABC123';  -- waits

Fix: keep transactions short, optimistic locking with version column, or queue updates per SKU in application.

DDL during traffic

ALTER TABLE orders ADD COLUMN promo_code text;
-- AccessExclusiveLock — blocks all queries on orders

Fix: use ADD COLUMN ... DEFAULT ... NOT NULL in PG 11+ (fast, minimal lock), or CREATE INDEX CONCURRENTLY instead of blocking index creation. Schedule DDL in maintenance windows for operations that require AccessExclusiveLock.

Foreign key checks

INSERT into child waits for parent row lock held by uncommitted transaction on parent table.

Advisory locks

Application-level locks:

SELECT pid, locktype, classid, objid, mode, granted
FROM pg_locks
WHERE locktype = 'advisory';

Used by migrations (Rails, Flyway), job schedulers, manual pg_advisory_lock(). Stuck advisory lock blocks next deploy.

Release:

SELECT pg_advisory_unlock_all();  -- current session only
SELECT pg_terminate_backend(pid); -- holder session

Deadlock detection

Postgres automatically detects deadlocks and cancels one transaction:

ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 67890; blocked by process 54321...

Log deadlocks:

log_lock_waits = on
deadlock_timeout = '1s'

Inspect deadlock graph in logs. Application should retry on 40P01 SQLSTATE.

Reduce deadlocks:

Remediation actions

Cancel query (graceful — transaction continues):

SELECT pg_cancel_backend(12345);

Terminate session (rollback transaction):

SELECT pg_terminate_backend(12345);

Kill idle in transaction (batch):

SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND now() - xact_start > interval '5 minutes'
  AND usename NOT IN ('postgres', 'replication');

Always record blocking query and application_name before termination for post-incident review.

Monitoring and alerting

Prometheus postgres_exporter metrics:

Alert when:

Dashboard query for lock wait rate:

SELECT count(*) FILTER (WHERE wait_event_type = 'Lock') AS waiting,
       count(*) AS total
FROM pg_stat_activity
WHERE backend_type = 'client backend';

Preventive schema and application design

-- Migration session
SET lock_timeout = '5s';
SET statement_timeout = '300s';
ALTER TABLE ... ;

pg_locks vs pg_stat_progress

For VACUUM and CREATE INDEX progress, use pg_stat_progress_vacuum and pg_stat_progress_create_index — not pg_locks. Lock monitoring complements progress views during long maintenance.

On-call lock triage cheat sheet

Keep this sequence visible in your runbook:

Step Action Query
1 Count lock waits SELECT count(*) FROM pg_stat_activity WHERE wait_event_type = 'Lock'
2 Find blockers SELECT * FROM ... pg_blocking_pids ... (full query above)
3 Classify blocker idle in transaction → terminate; DDL → wait or cancel migration; hot row → app fix
4 Check duration now() - xact_start on blocker
5 Terminate if safe pg_cancel_backend first, then pg_terminate_backend
6 Verify recovery Lock wait count returns to zero

Post-incident: if idle-in-transaction caused the block, grep application code for missing COMMIT. If DDL caused it, add lock_timeout to migration tooling.

Summary

pg_locks combined with pg_stat_activity reveals who blocks whom and why. Use pg_blocking_pids on PG 14+ for fast blocking trees, distinguish RowExclusive contention from AccessExclusive DDL disasters, and terminate idle-in-transaction sessions aggressively with timeout configuration. Prevent blocking with short transactions, lock_timeout on migrations, CONCURRENTLY operations, and application-level retry on deadlocks. Lock monitoring is not optional infrastructure — it is the primary diagnostic during database-related incidents.

Set lock_timeout on migration sessions so DDL fails fast instead of becoming the head of an invisible lock queue that 503s the app.

Frequently asked questions

How do I find which query is blocking others?

Join pg_stat_activity with pg_locks on the blocked and blocking PIDs. In PG 14+, pg_blocking_pids(pid) returns blocking backend PIDs directly. Look for blocked sessions in wait_event_type = 'Lock' and trace to the holder's query in pg_stat_activity.

What is the difference between RowExclusiveLock and AccessExclusiveLock?

RowExclusiveLock (INSERT/UPDATE/DELETE) conflicts with Share, ShareRowExclusive, Exclusive, and AccessExclusive. AccessExclusiveLock (ALTER TABLE, DROP, VACUUM FULL) conflicts with everything — blocks all reads and writes. DDL during peak traffic causes AccessExclusiveLock queues that cascade.

Should I kill blocking sessions with pg_terminate_backend?

Terminate only after confirming the blocking query is safe to interrupt — long-running analytics, forgotten idle-in-transaction, or runaway migration. Use pg_cancel_backend first for graceful query cancel; pg_terminate_backend for stuck idle-in-transaction. Document the blocking query before termination for post-incident review.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →