open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 07 · 05

Deadlocks

A deadlock is a lock cycle: two transactions each hold a row the other needs. Postgres detects the cycle, kills a victim with error 40P01, and you retry — but consistent lock ordering prevents it entirely.

SQL Senior ◷ 15 min
Level
FoundationsJuniorMiddleSenior

A payments service deadlocked roughly forty times an hour at peak — each one a transaction killed mid-flight with ERROR: deadlock detected. The transfers were correct in isolation; the bug was that transfer(A, B) locked account A then B, while transfer(B, A) locked B then A. Whenever the two ran at once they grabbed one row each and then waited forever for the other’s. Postgres broke the standoff by shooting one transaction. The permanent fix changed one line: always lock the lower account id first. Zero deadlocks since.

Anatomy of a deadlock

When you see ERROR: deadlock detected in production logs, the instinct is to tune deadlock_timeout or add retries and move on. Resist that. Every deadlock is pointing at an ordering inversion that a one-line fix can eliminate permanently.

A deadlock needs exactly one ingredient: two transactions that acquire the same locks in opposite order. Each grabs its first lock, then asks for the second — which the other already holds — and neither will release until it gets what it’s blocked on. That’s a cycle in the wait-for graph, and no amount of waiting resolves it.

-- Transaction 1                          -- Transaction 2
BEGIN;                                     BEGIN;
UPDATE accounts SET balance = balance - 100
  WHERE id = 1;       -- locks row 1
                                           UPDATE accounts SET balance = balance - 50
                                             WHERE id = 2;     -- locks row 2
UPDATE accounts SET balance = balance + 100
  WHERE id = 2;       -- WAITS for row 2 (held by Tx2)
                                           UPDATE accounts SET balance = balance + 50
                                             WHERE id = 1;     -- WAITS for row 1 (held by Tx1)
-- now both wait on each other forever -> deadlock

How Postgres detects and resolves it

Postgres does not check for deadlocks on every lock wait — that would be expensive. Instead, when a transaction blocks on a lock, it waits deadlock_timeout (default 1 second) and only then runs the deadlock detector, which walks the wait-for graph looking for a cycle. If it finds one, it picks a victim and aborts it with:

ERROR:  deadlock detected
DETAIL:  Process 1234 waits for ShareLock on transaction 5678; blocked by process 4321.
         Process 4321 waits for ShareLock on transaction 8765; blocked by process 1234.
HINT:    See server log for query details.
SQLSTATE: 40P01

The victim’s transaction is rolled back entirely; the survivor proceeds. The 1-second deadlock_timeout means a genuine deadlock costs at least ~1s of latency before resolution, so frequent deadlocks are both a correctness smell and a latency problem. Lowering the timeout makes detection faster but adds CPU on every ordinary lock wait — don’t tune it before fixing the ordering.

Prevention beats detection

Detection is a safety net, not a strategy. The cure is consistent lock ordering: if every transaction that touches multiple rows always locks them in the same global order, a cycle is impossible.

-- Deadlock-proof transfer: lock both rows in one statement, lowest id first.
BEGIN;
SELECT id FROM accounts
WHERE id IN (:from_id, :to_id)
ORDER BY id           -- the magic: deterministic order, both directions
FOR UPDATE;
UPDATE accounts SET balance = balance - :amt WHERE id = :from_id;
UPDATE accounts SET balance = balance + :amt WHERE id = :to_id;
COMMIT;

Because transfer(1, 2) and transfer(2, 1) now both lock id 1 before id 2, one fully acquires before the other starts — no cycle. The other levers, in priority order: keep transactions short (less time holding locks = smaller collision window), touch rows in a stable order app-wide, and only then consider NOWAIT to fail fast instead of waiting out the timeout.

Edge cases

Deadlocks aren’t only your explicit UPDATEs. Foreign keys take locks too: inserting a child row locks the parent with FOR KEY SHARE, and two transactions inserting children of two parents while also updating those parents can deadlock in non-obvious ways. Index and unique-constraint contention can also produce cycles when concurrent inserts touch the same index pages or conflict on the same key. The jobs queue from the previous lesson is a worked example: workers using FOR UPDATE SKIP LOCKED never deadlock on each other because nobody waits — they skip — which is one more reason SKIP LOCKED is the right queue primitive. When you do hit a deadlock, read the server log’s DETAIL: it names the two processes and the locks in the cycle, which is usually enough to spot the ordering inversion. Always make the victim’s caller retry the whole transaction with capped backoff; a retried transfer re-locks in the correct order and succeeds.

Quiz

Two transfer transactions, transfer(1,2) and transfer(2,1), run concurrently and each locks its source account first. What happens and what is the durable fix?

Quiz

Your app catches SQLSTATE 40P01 (deadlock). What is the correct response in application code?

Order the steps

Order the lifecycle of a deadlock from formation to resolution to permanent fix:

  1. 1 Two transactions lock the same rows in opposite order, each holding one and wanting the other
  2. 2 A blocked transaction waits out deadlock_timeout (default ~1s)
  3. 3 The deadlock detector finds the cycle and aborts one victim with SQLSTATE 40P01
  4. 4 The victim's caller retries the whole transaction with backoff
  5. 5 You change the code to lock rows in a consistent order (e.g. ORDER BY id), so the cycle can never form again
Recall before you leave
  1. 01
    What exactly is a deadlock, and what single condition causes the classic two-row case?
  2. 02
    How does Postgres detect and resolve a deadlock, and what is the cost?
  3. 03
    What is the durable fix for deadlocks and what must application code still do?
Recap

A deadlock is the one concurrency failure Postgres resolves for you — by killing a transaction. It forms when two transactions acquire the same locks in opposite order: each holds one resource and blocks forever on the other, a cycle in the wait-for graph. Postgres doesn’t poll for this on every wait; a blocked transaction sits for deadlock_timeout (default ~1 second) before the detector runs, finds the cycle, and aborts a victim with ERROR: deadlock detected, SQLSTATE 40P01, rolling its work back entirely while the survivor commits. Because the victim’s transaction vanished, your application must retry it with capped backoff. But retrying is the band-aid; the cure is prevention. Lock multiple rows in a consistent global order — the canonical trick is ORDER BY id ... FOR UPDATE so every direction of a transfer locks the lower id first — keep transactions short to minimize the window, and remember that foreign-key and index contention can deadlock too. The FOR UPDATE SKIP LOCKED queue from the previous lesson sidesteps deadlocks by never waiting. Read the log’s DETAIL to find the inverted ordering, fix the order once, and the deadlocks stop.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.