Deadlock Detection: The Waits-For Graph
Two transactions each holding what the other needs will wait forever; the lock manager's wait queues already encode "who waits for whom" as a directed graph, a depth-first search finds the cycle, and the engine breaks it by aborting one participant — PostgreSQL after a one-second grace period, InnoDB immediately.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
TX A locks account 1 and asks for account 2. TX B locks account 2 and asks for account 1. Both are queued in the lock manager, both are asleep, and no release will ever come. The locking protocol is correct; it has produced a state from which there is no progress.
↓ - Naive solution
Give every lock wait a timeout. After 50 seconds, abort.
↓ - Why it breaks
A real deadlock costs 50 seconds of two stalled sessions and every session queued behind them. A legitimate long wait — a lock held by a big but healthy transaction — is killed for nothing. The timeout is wrong in both directions, and no single value fixes it.
↓ - Better idea
The wait queues already say exactly who is waiting on whom. A deadlock is a cycle in that relation. Look for the cycle instead of guessing from elapsed time.
↓ - Internal mechanism
Build a directed graph with a node per transaction and an edge from each waiter to each holder that blocks it; run a depth-first search from the transaction that just blocked; a back edge is a cycle. Pick a victim on the cycle, abort it, release its locks; the others wake up.
↓ - Trade-offs
Detection costs a graph walk under the lock-table latches, so it must be cheap or rare. Aborting a victim throws away its work, so the choice of victim matters. It is detection, not prevention: deadlocks still happen, they are just short.
↓ - Real database
PostgreSQL waits
deadlock_timeout(1 s) before searching and aborts the transaction that ran the search; InnoDB searches on every lock wait and aborts the transaction with the least undo. Both leave prevention — consistent lock ordering — to the application.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
Deadlock is not a bug in the lock manager; it is the lock manager working perfectly on two requests that cannot both be satisfied. The engine cannot make both proceed, so it picks one, cancels it with an error, and lets the other finish. The application's job is to retry the cancelled one and, better, to order its locks so the cycle never forms.
Two transactions, two rows
Transfer A moves money from account 1 to account 2; transfer B moves money from 2 to 1. Each updates its source first. A takes the exclusive lock on row 1; B takes the exclusive lock on row 2. A now requests row 2 and is queued behind B; B requests row 1 and is queued behind A. Both are asleep on their semaphores. Both hold a lock the other needs, and neither will release anything until it commits, which it cannot do until it gets the lock. The lock manager has done nothing wrong.
time TX A (1038) TX B (1041)
1 UPDATE accounts SET … WHERE id=1
-> X lock row 1: granted
2 UPDATE accounts SET … WHERE id=2
-> X lock row 2: granted
3 UPDATE accounts SET … WHERE id=2
-> X lock row 2: WAIT (held by 1041)
4 UPDATE accounts SET … WHERE id=1
-> X lock row 1: WAIT (held by 1038)
lock table
(accounts, row 1) granted [1038: X] waiting [1041: X]
(accounts, row 2) granted [1041: X] waiting [1038: X]The waits-for graph
Read the lock table's wait queues as edges. Every waiting request contributes an edge from the waiter to each transaction whose granted lock conflicts with it (and, in engines that queue strictly, to the waiters ahead of it that conflict too). Nodes are transactions, not resources: the resource is what labels the edge. The result is the waits-for graph — a Directed Graph that the lock manager maintains implicitly and that the detector materialises when it needs to.
A deadlock is exactly a cycle in this graph. If there is no cycle, every chain of waiting ends at a transaction that is running and will eventually commit or abort, releasing the chain. A cycle means every member is waiting on another member; no external event will resolve it. Longer cycles happen in practice — A waits for B waits for C waits for A — and are just as fatal, which is why counting on "I only ever lock two rows" is not a defence.
nodes: 1038, 1041, 1042
edges (waiter -> holder, labelled by resource):
1038 -> 1041 [accounts row 2]
1041 -> 1038 [accounts row 1]
1042 -> 1038 [accounts row 1] (1042 also wants row 1; blocked, but not in the cycle)
┌──────────┐
1038 ──► 1041 cycle of length 2: deadlock
◄──────────┘
1042 ──► 1038 waits on the cycle; will be freed when it breaksFinding the cycle
Cycle detection in a directed graph is a depth-first search that colours nodes: white (unvisited), grey (on the current path), black (finished). Following an edge into a grey node is a back edge, and a back edge closes a cycle. That is the whole algorithm from Cycle Detection, and the waits-for graph is small enough — tens of nodes, one edge per blocked request — that a search from the transaction that just blocked costs microseconds. The search starts from the new waiter because any cycle that did not exist a moment ago must pass through the edge just added.
The engineering questions are around the search rather than inside it. The graph must be read consistently, which means holding the lock-table latches (all sixteen partitions, in PostgreSQL) while walking; that is why the search should be rare or fast. The edges must reflect the queue policy: PostgreSQL treats a waiter as blocked by the holders *and* by conflicting waiters ahead of it in the queue, since it will not be granted before them either. And the result must be acted on before the latches are dropped, or the graph can change underneath.
1def deadlocked(start):2 on_path = set()3 def visit(t):4 if t in on_path: return True # back edge: cycle through t5 on_path.add(t)6 for holder in blockers_of(t): # granted holders + conflicting waiters ahead7 if visit(holder): return True8 on_path.remove(t)9 return False10 return visit(start)11 12# on block: PostgreSQL: after deadlock_timeout elapses13# if deadlocked(me): abort a victim, release its locks, wake the queueChoosing the victim
Once a cycle is found, one member must be aborted; its abort releases its locks, which grants the next waiter, which unwinds the cycle. Which member? The searcher — the transaction that just blocked and found the cycle — is the simplest choice and the one PostgreSQL makes: it errors out its own request with deadlock detected and reports the cycle in the error detail. It is fair in the sense that the transaction that completed the cycle pays for it, and it needs no global comparison. The cheapest to redo is InnoDB's choice: it compares the transactions on the cycle by the number of rows they have modified and locks they hold and aborts the one with the smallest weight, so that the most work survives. The youngest is what timestamp-ordered schemes pick, which guarantees the oldest transaction eventually commits and no transaction is starved by being repeatedly chosen.
Whatever the rule, the victim's session receives an error and its transaction is rolled back. The application must retry — the whole transaction, from BEGIN — because the schedule that failed has been discarded. A deadlock error treated as a fatal bug rather than a retryable condition is the most common mishandling.
Timeouts versus detection
PostgreSQL combines the two. A blocked process first simply sleeps. If it is still waiting after deadlock_timeout — one second by default — it wakes, takes all lock-table partition latches, builds the waits-for graph and runs the search. No cycle: it goes back to sleep and never searches again for this wait (it may log the wait if log_lock_waits is on, which is the cheapest way to find lock contention in production). Cycle: it first tries to break it by reordering wait queues without aborting anyone, and only if that fails aborts itself with SQLSTATE 40P01.
The delay is a bet that most waits are short and healthy, so the search — which stalls the whole lock manager — should not run on every block. Lowering deadlock_timeout finds deadlocks faster at the cost of more searches on a contended system; raising it is done on systems where lock waits of several seconds are normal. lock_timeout is a different, unrelated knob: it aborts *any* wait after the interval, deadlock or not, and is the tool for migrations that must not queue behind long transactions.
Timeouts versus detection
InnoDB searches immediately: every lock wait triggers a depth-first walk of the waits-for graph before the requester sleeps (innodb_deadlock_detect = ON), so a deadlock is reported at the instant it forms. On systems with very many concurrent waiters the walk itself becomes the bottleneck, and the documented remedy is to disable detection and rely on innodb_lock_wait_timeout (50 seconds by default) — the naive solution, chosen deliberately because at that scale the search is worse. The victim is the transaction with the least undo, and SHOW ENGINE INNODB STATUS prints the last detected deadlock in full: both transactions, both statements, the locks held and waited for.
Prevention: making cycles impossible
Detection limits the damage of a deadlock; it does not reduce how many happen. Prevention does, and it is almost entirely the application's responsibility. Lock ordering: if every transaction acquires its locks in a single global order — ascending account id, say — a cycle cannot form, because a cycle would require some transaction to wait for a lock that sorts *before* one it already holds. SELECT … WHERE id IN (1, 2) ORDER BY id FOR UPDATE before the two UPDATEs is the whole fix for the transfer example, and the practical lesson Locks and Deadlocks shows the code. Short transactions shrink the window in which a cycle can close. Taking the coarse lock first — one row that represents the whole operation — serialises intentionally at the cost of concurrency.
Protocol-level prevention exists too. Wait-die and wound-wait compare transaction start times: a transaction is only ever allowed to wait for an older (wait-die) or a younger (wound-wait) one, so waits are all one direction and cannot cycle; the disallowed direction aborts instead. Neither is used by PostgreSQL or InnoDB for row locks — the abort rate is too high for their workloads — but both appear in distributed transaction managers where assembling a global waits-for graph would need a round of messages per wait.
Key points
- A deadlock is a cycle in the waits-for graph: waiter → holder edges read straight off the lock table's wait queues.
- Detection is DFS cycle detection from the transaction that just blocked; the graph is tiny, the latches it needs are not.
- PostgreSQL searches after deadlock_timeout (1 s) and aborts the searcher with 40P01; InnoDB searches on every wait and aborts the transaction with the least undo.
- Timeouts alone are wrong in both directions; detection finds real deadlocks fast and leaves healthy long waits alone.
- Prevention is lock ordering and short transactions; wait-die and wound-wait prevent by protocol at the cost of extra aborts.
Deadlock detection: the waits-for graph
PostgreSQL waits deadlock_timeout (1 s) before running the DFS, because most waits resolve on their own and the graph walk takes the lock-manager locks. InnoDB checks on every new wait edge (with a cap of 200 nodes). Cost: O(V + E) per check — trivial next to a 1 s wait.
Give up after innodb_lock_wait_timeout (50 s) or lock_timeout (off by default in PostgreSQL). Simple, but it cannot tell a deadlock from a slow holder, wastes the whole timeout, and picks whoever waited longest, regardless of how much work it loses.
When to use — and when not
- Graph-based detection fits any single-node engine with a central lock manager: the graph is already there, and a search on demand is cheap.
- Timeout-based handling fits when detection itself contends — very high waiter counts — or across nodes where the graph is not local.
- Wait-die / wound-wait do not fit workloads with long transactions and frequent conflicts: the mandatory aborts dominate.
- Relying on detection does not fit code paths that lock in inconsistent order — the deadlocks are detected, and they keep happening.
Failure modes
- Retrying only the failed statement instead of the whole transaction after 40P01.
- A one-second deadlock_timeout mistaken for slow locking: the deadlock was resolved as fast as the design allows.
- Cycles of three or more transactions across code paths that each "only lock two rows".
- Disabling InnoDB deadlock detection without setting a sane lock wait timeout.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.
- DSACycle detection by DFS (back edges) → Deadlock detection in the waits-for graphSame algorithm; the graph is built from lock wait queues instead of an adjacency list.
- DSADepth-first search → The walk PostgreSQL's deadlock checker performs
- Operating SystemsDeadlock conditions (mutual exclusion, hold-and-wait, no preemption, circular wait) → Transaction deadlock; lock ordering removes circular waitDatabases resolve it by preemption — aborting a victim — which an OS mutex cannot do because it cannot roll back a thread.