Deadlocks in PostgreSQL: causes, reading the log, fixes Intermediate
Estimated reading time: 7 min read
Read first
Symptoms
Section titled “Symptoms”A money-transfer application suddenly gets errors from PostgreSQL, SQLSTATE 40P01 (deadlock_detected). Each transaction succeeds when run alone; run concurrently, they take locks in opposite orders and form a wait cycle.
Distinguish two things:
- Lock wait: A holds a row, B waits for A. When A commits, B proceeds.
- Deadlock: A waits for something B holds while B waits for something A holds. Neither can make progress on its own, so PostgreSQL has to cancel one transaction.
Root cause
Section titled “Root cause”A updates row 1 and holds the lock until the transaction ends.
| Time | Session A | Session B | Can proceed? |
|---|---|---|---|
| 1 | Holds row 1 | Yes | |
| 2 | Holds row 2 | Yes | |
| 3 | Wants row 2, waits for B | B can still run | |
| 4 | Waits for B | Wants row 1, waits for A | No: the cycle is closed |
Once a lock wait lasts deadlock_timeout (default 1s), PostgreSQL checks for deadlock. This is a wait threshold before checking, not a periodic scan job. If it finds a cycle, it cancels one transaction.
The docs state that which transaction gets canceled is “difficult to predict and should not be relied upon”, so the code must handle both roles.
Reproducing and reading the error
Section titled “Reproducing and reading the error”Run examples/docker-compose.yml, then examples/src/postgres-locks/deadlock-demo.ts. The script opens two connections and has them update two rows in opposite orders:
// Session Aawait sessionA.query('UPDATE accounts SET balance = balance - 100 WHERE id = 1'); // A holds row 1// Session Bawait sessionB.query('UPDATE accounts SET balance = balance - 50 WHERE id = 2'); // B holds row 2
// A tries to update row 2 (held by B) → A waitsconst a2 = sessionA.query('UPDATE accounts SET balance = balance + 100 WHERE id = 2');// B tries to update row 1 (held by A) → B waits → the cycle closesconst b2 = sessionB.query('UPDATE accounts SET balance = balance + 50 WHERE id = 1');Real output from a run:
--- Result ---Session A: ERROR — deadlock detected code: 40P01 detail: Process 528 waits for ShareLock on transaction 760; blocked by process 527.Process 527 waits for ShareLock on transaction 759; blocked by process 528. hint: See server log for query details.Session B: succeededThe two DETAIL lines together form the cycle: process 528 waits for 527 and 527 waits for 528. PIDs and transaction IDs change every run; only read the “who waits for whom” relationship. Classify the error by the code 40P01, not by matching the string deadlock detected.
Four common real-world deadlock patterns
Section titled “Four common real-world deadlock patterns”Deadlocks are rarely two hand-written UPDATEs like above. More common:
| Pattern | Why it deadlocks | Fix |
|---|---|---|
Batched UPDATE/INSERT ... ON CONFLICT from two workers, each iterating rows in a different order |
Two batches lock the same row set in different orders | Sort by key before sending, or ORDER BY id in the statement |
Two tables, opposite order: flow 1 updates orders then inventory, flow 2 updates inventory then orders |
Cycle across two tables | A codebase-wide table order convention |
Foreign keys: one side DELETEs or SELECT FOR UPDATEs the parent row, the other inserts a child row (taking FOR KEY SHARE on the parent) and then modifies that parent |
FOR UPDATE conflicts with FOR KEY SHARE (see row-level locks) |
Lock the parent row first in every flow; keep transactions short |
SELECT ... FOR UPDATE on a join |
No control over which table gets locked first | FOR UPDATE OF <table> and lock in the agreed order |
Detection
Section titled “Detection”- Look for
40P01in the app andERROR: deadlock detectedin the server log; readDETAILto connect the processes. - Enable
log_lock_waits: PostgreSQL logs when a lock wait exceedsdeadlock_timeout. It is for investigating lock waits, not a prerequisite for deadlock detection to work. - Watch the counter:
SELECT datname, deadlocks FROM pg_stat_database WHERE datname = current_database();The counter accumulates since the last statistics reset, so alert on its rate of increase and on the app’s retry rate, not on the absolute value.
It does not tell you which query caused the error: group the DETAIL lines by endpoint to find it. A sudden jump in deadlocks after a deploy is almost always caused by the code path that just changed.
Immediate fix
Section titled “Immediate fix”Long-term prevention
Section titled “Long-term prevention”Lock ordering: every flow locks the same row set in the same order. The crude approach is two SELECT ... FOR UPDATE statements in ascending id in the app. Better is a single statement:
SELECT id FROM accountsWHERE id IN ($1, $2)ORDER BY idFOR UPDATE;One statement saves a round trip and lets the database lock in sort order. Note the docs warn that under Read Committed, ORDER BY combined with a locking clause can return rows out of order if a row is modified while waiting for the lock.
Also check locks taken indirectly through triggers, foreign keys or other tables. Example 1 → 2 and 2 → 1: if the code locks “sender first, receiver second”, the two transfers take locks in opposite orders.
If both lock the smaller id first, the later transfer waits at id 1 while holding nothing, so no cycle forms.
Extra layers of protection:
| Tool | Purpose |
|---|---|
lock_timeout |
Caps the wait for one lock (default 0, i.e. wait forever); set it for the app |
statement_timeout |
Caps the duration of the whole statement |
deadlock_timeout |
Raise it (default 1s) to reduce wasted checks on a very busy server, at the cost of reporting real deadlocks later. Ideally larger than a typical transaction |
| Bounded retry | Rerun the whole transaction on 40P01; jitter, a few attempts at most, then a clear error |
A retry must restart from the first read: data read earlier may be stale. If the transaction triggers external side effects, you need idempotency.
Production checklist
Section titled “Production checklist”- Every flow that locks several rows or tables follows one agreed order (sorted by key).
- A retry wrapper for
40P01(and40001): rerun the whole transaction, bounded, with jitter. - Set
lock_timeoutandstatement_timeoutfor the app,log_lock_waits = onfor the server. - Alert on the rate of increase of
pg_stat_database.deadlocksand the retry rate after each deploy. - Group log
DETAILlines by endpoint to find the code path forming the cycle. - Short transactions: no slow network calls while holding locks.
- Side effects outside the database go through an outbox or idempotency.
Trade-offs
Section titled “Trade-offs”Lock ordering and retry
- Lock ordering eliminates the whole class of lock-order deadlocks, no retry needed.
- Bounded retry covers the rare remaining deadlocks cheaply.
lock_timeoutturns infinite waits into controlled errors.
Costs to manage
- Lock ordering must be maintained across the whole codebase; one out-of-order code path is enough to cause errors.
- Retries repeat work and can repeat side effects without idempotency.
- Raising
deadlock_timeoutreports real deadlocks later.