Debugging PostgreSQL Deadlocks: Root Cause, Row-Level Lock Queues & Advisory Locks

When concurrent database transactions attempt to acquire conflicting row-level or table-level locks in cyclic dependencies, the PostgreSQL lock manager throws:

ERROR:  deadlock detected
DETAIL:  Process 21402 waits for ShareLock on transaction 849201; blocked by process 21408.
Process 21408 waits for ExclusiveLock on tuple (42, 18) of relation "orders" of database 16384; blocked by process 21402.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (42, 18) in relation "orders"

A common anti-pattern is wrapping queries in exponential backoff retry loops. While retrying mitigates transient collisions, it drastically increases query latency tail percentiles ($P_{99}$) and conceals architectural race conditions under peak traffic.

Here is the exact diagnostic workflow to pinpoint deadlocking queries and restructure locking order.


1. Verified Production Environment

  • Database Engine: PostgreSQL 16.3 (x86_64 Linux on AWS RDS / Self-hosted)
  • Transaction Isolation Level: READ COMMITTED (default)
  • Connection Pooler: PgBouncer 1.22.x in transaction pooling mode
  • Application Framework: Spring Boot 3.3.x (HikariCP 5.1.0) / Node.js pg-promise

2. Common False Fix: Why Application-Level Retries Fail

Consider two microservice endpoints updating orders and inventory balances concurrently:

# WRONG FIX: Blind retry loops without lock order synchronization
@retry(exceptions=DeadlockDetected, max_attempts=5, backoff=0.2)
def execute_order_settlement(order_id, user_id, amount):
    with db.transaction():
        # Service A: Locks user balance first, then updates order status
        update_user_balance(user_id, amount)
        update_order_status(order_id, 'COMPLETED')
# Concurrently executed by automated invoice reconciliation job:
with db.transaction():
    # Service B: Locks order status first, then audits user balance
    update_order_status(order_id, 'PROCESSING')
    audit_user_balance(user_id)

If Service A locks user_balance while Service B locks orders, both workers block each other. After deadlock_timeout (default 1s), PostgreSQL aborts one transaction. Retrying merely repeats the contention cycle under high throughput, creating database CPU spikes.


3. Investigating Lock Contention with pg_locks

To inspect live lock wait queues before the deadlock detector terminates transactions, execute:

SELECT
    blocked_locks.pid     AS blocked_pid,
    blocked_activity.usename  AS blocked_user,
    blocking_locks.pid    AS blocking_pid,
    blocking_activity.usename AS blocking_user,
    blocked_activity.query    AS blocked_statement,
    blocking_activity.query   AS blocking_statement
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;

4. Architectural Solutions

Solution A: Strict Deterministic Ordering (Alphabetical / ID Sort)

Whenever updating multiple rows in batch, always sort primary keys in ascending order prior to issuing SQL statements:

- UPDATE accounts SET balance = balance - 100 WHERE id IN (85, 12, 44);
+ -- Sort IDs beforehand in application memory: [12, 44, 85]
+ UPDATE accounts SET balance = balance - 100 WHERE id = 12;
+ UPDATE accounts SET balance = balance - 100 WHERE id = 44;
+ UPDATE accounts SET balance = balance - 100 WHERE id = 85;

When every worker acquires row locks in identical numerical sequence ($12 \to 44 \to 85$), cyclic deadlocks become mathematically impossible.

Solution B: Application-Level Advisory Locks

For complex workflows spanning multiple tables where row sorting is impractical, use PostgreSQL advisory locks:

-- Acquire transactional advisory lock based on user_id hash
SELECT pg_advisory_xact_lock(hashtext('user_account_' || :userId));

-- Proceed with safe multi-table operations
UPDATE accounts SET balance = balance - :amount WHERE user_id = :userId;
UPDATE orders SET status = 'PAID' WHERE user_id = :userId;

-- Lock automatically releases upon COMMIT or ROLLBACK

About the Author

Justin (IdeaSpot360 Lead Engineer) — Cloud & Backend Systems Architect specializing in distributed systems, Kubernetes, and high-throughput backend services.