DeepConcepts

Postgres / concurrency / locking / deadlock detection

Deadlocks: Nothing Is Watching Until Your Own Timer Fires

The misconception

That a deadlock detector is watching the lock table, so cycles are caught as they form, and that deadlock_timeout is how long the server tolerates one before breaking it. There is no watcher. The check is optimistic and self-service: it runs once per lock wait, deadlock_timeout after that wait began, and never again for that wait. Raising the setting does not prevent a single deadlock — in a contended workload it leaves the whole queue behind the cycle stuck for exactly that much longer, and it blinds log_lock_waits at the same time, because both use the same timer.

17 min

There is no deadlock detector running in your Postgres server. Nothing scans the lock table looking for cycles. A backend that cannot get a lock goes straight to sleep, sets a one-shot alarm for deadlock_timeout — one second by default — and only when that alarm goes off does it get up and look for a cycle itself. Once. If it finds one it is part of, it kills its own transaction.

That design has a name in the source: optimistic waiting. The reasoning is in src/backend/storage/lmgr/README and it is sound — checking for a cycle means taking every partition of the lock table exclusively, so doing it on every lock wait would cost more than the deadlocks do. But the consequences are not what the setting's name suggests, and almost every piece of advice about deadlock_timeout gets them backwards.

The panel runs 30 seconds of a contended workload. Eight sessions each open a transaction, lock three rows out of a pool of twenty, and commit. Nobody writes LOCK TABLE; these are ordinary UPDATE statements, and the lock on each row is taken implicitly, one row at a time, in whatever order the rows arrive. The control that carries the lesson is deadlock_timeout. Move it and watch two numbers that most people expect to move together.

Locking a row takes 3 ms of work, a commit 2 ms, and a rolled-back transaction is retried 5 ms later with a fresh set of rows. Waiters queue on the row in arrival order, which is how the tuple lock behaves: Postgres takes an exclusive lock on the tuple itself to serialise the queue before anyone waits on the row's current owner.

commits per second
deadlocks per 1,000 commits
deadlock errors in 30 s
longest single lock wait
checks that found nothing
cycles seen and ignored
dodged by reordering the queue
Each session across the 30 seconds

holding locks and making progress · waiting for a row someone else holds · a wait that ended in this session aborting itself. The red span covers the whole wait, so its length is how long the session stood there before its own timer fired — the cycle itself closed somewhere inside it.

The waits-for graph, the hard and soft edge rules, the one-shot timer, the choice of victim and the decision to ignore a cycle the checker is not part of are all taken from deadlock.c, proc.c and the lock manager README in PostgreSQL 18. The millisecond costs of locking, committing and retrying are invented — they set the scale of the contention, not the behaviour of the detector. Process ids and transaction ids in the log are made up so the messages read like real ones.

At the defaults, 376 transactions commit in 30 seconds and 34 die with ERROR: deadlock detected. Now drag deadlock_timeout down to 10 ms. The deadlock count explodes from 34 to 994 — and the workload gets twenty times better: 7,744 transactions commit instead of 376. Per thousand commits the deadlock rate barely moves, 90 to 128. The setting never prevented a single deadlock. It decided how long everyone queued behind one had to stand there.

Now drag it the other way, to 5 s. Deadlock errors fall to 5, which looks like a fix, and 67 transactions commit in 30 seconds. The longest single lock wait is 10,019 ms. That is what "reducing deadlocks" by raising the timeout buys: a system that has almost stopped, reporting almost no errors.

The fix is not on this panel's timeout slider at all. Set row visit order to sorted by primary key before locking: 10,699 commits, zero deadlocks, and the detector never runs once.

What the check actually does when it wakes up

The waits-for graph is the standard one: a node per process, an edge from A to B when A is waiting for a lock that B holds in a conflicting mode. A deadlock is a cycle. What is not standard is everything around it.

The check is armed once per wait. ProcSleep calls enable_timeout_after(DEADLOCK_TIMEOUT, DeadlockTimeout) before it goes to sleep, and if the timer fires, CheckDeadLock runs, sets a state variable, and the process goes back to sleep. The timer is not re-armed. A process that has waited two minutes has examined the graph exactly once, 119 seconds ago.

That sounds like a hole, and it is not, because of the shape of a cycle: a cycle is only complete once its last member starts waiting, and that member's timer has not fired yet when it joins. So every hard deadlock is found within one deadlock_timeout of the moment the cycle closed — by whichever member's alarm is the first to go off after that, which is often a process that blocked much earlier and whose timer merely happened to land on the right side of the last arrival. Which is also the answer to "why did it kill that transaction" — Postgres does not choose a victim. There is no equivalent of SQL Server's DEADLOCK_PRIORITY, no cost heuristic, no preference for the younger transaction. The process that runs the check aborts itself, and the documentation says as much: "Exactly which transaction will be aborted is difficult to predict and should not be relied upon."

A cycle you are not part of is deliberately left alone. This is the line in the README that surprises people most: if the search reaches a cycle that loops back to some node other than the starting process, the checker reports no deadlock, "on the grounds that resolving such a deadlock is the responsibility of the processes involved — killing our start-point process would not resolve the deadlock." Watch the cycles seen and ignored readout at the defaults: 33 times in 30 seconds a process looked straight at a deadlock and went back to sleep, correctly. The processes in it have their own timers and will handle it.

Some cycles are broken without killing anyone. Postgres distinguishes two kinds of edge. A hard edge is the ordinary one: A waits because B holds a conflicting lock. A soft edge exists when A is merely behind B in the same lock's wait queue with a conflicting request — A waits for B not because B has anything, but because ProcLockWakeup grants in arrival order and will never wake A first. A cycle made only of hard edges is fatal. A cycle containing even one soft edge can be dissolved by reversing that edge, which means topologically re-sorting the wait queue so the two processes swap places. Nobody is aborted, and the only trace is a log line — process 41236 avoided deadlock for ExclusiveLock on tuple (14,3) by rearranging queue order after 1000.213 ms — which you will not see, because log_lock_waits is off by default in PostgreSQL 18.

There is one case that skips the wait entirely. If you are about to join a queue for an object on which you already hold a conflicting lock, and the waiter ahead of you is blocked by that lock, JoinWaitQueue in proc.c spots the two-way deadlock before anyone sleeps: it asks "must he wait for me?" and then "must I wait for him?", and if both are true it sets early_deadlock and returns PROC_WAIT_STATUS_ERROR instead of queueing. That is the lock-upgrade deadlock: two sessions each holding a shared lock on the same row and each trying to take it exclusive. It is the one deadlock Postgres reports in microseconds rather than in deadlock_timeout milliseconds.

Where the lock order comes from, and why it is not yours

"Acquire locks in a consistent order" is correct advice and it is what the row visit order control demonstrates: sorted gives 10,699 commits and zero deadlocks against 376 and 34. The problem is that in a workload made of plain UPDATE and DELETE statements, you are not the one choosing the order. The plan is.

A statement locks each row as its scan produces it, so the lock order is the row order of the access path:

  • An index scan produces rows in index-key order — the order of whichever index the planner picked, which is not necessarily the primary key.
  • A bitmap heap scan produces them in physical block order, because the bitmap is sorted by block to make the heap reads sequential. Its output order therefore has nothing to do with the index that built it.
  • A sequential scan produces them in heap order — and an updated row's new version is written wherever there is free space, usually at the end of the file, so heap order changes under you as the table is written to.
  • A parallel scan of any kind interleaves blocks across workers non-deterministically, so two executions of one statement can differ.

Set row visit order to heap order, re-read every transaction. Every session now follows the same rule — sort by physical position — and the run still produces 38 deadlocks across 528 commits. Nobody is inconsistent; the order itself moved between one transaction starting and the next. This is the case that looks impossible from the outside, and it is the reason two runs of the same DELETE statement can deadlock with each other.

Two more sources of lock order that are not in your SQL at all. A foreign key check takes a KEY SHARE lock on the parent row, in the order the child rows are processed — so inserting a batch of children that reference two shared parents will deadlock against another batch that references them in the other order, with no UPDATE of the parent anywhere. And INSERT ... ON CONFLICT DO UPDATE takes a row lock on whatever it collides with, in the order your VALUES list happens to be in, which is a lock order chosen by whoever built the array.

UPDATE ... WHERE id = ANY(ARRAY[7,3,9]) does not lock in 7, 3, 9 order. Sorting that array in your application changes nothing, because the array is a filter, not an iteration. What does work is making the ordering explicit and separate:

SELECT id FROM accounts
WHERE  id = ANY($1)
ORDER  BY id
FOR    UPDATE;                 -- locks in id order, one row at a time

UPDATE accounts SET ... WHERE id = ANY($1);   -- now conflict-free

The SELECT ... FOR UPDATE pass locks the rows in the order the ORDER BY produced, and by the time the UPDATE runs this transaction already holds every row lock it needs, so the UPDATE's own visit order cannot matter. That trick and its costs are the subject of their own lesson — including the fact that FOR UPDATE with ORDER BY only helps if every writer does it.

The boundary: where none of this helps

Push rows locked per transaction to 6 with everything else at the defaults. The run produces 9 commits and 36 rollbacks — 4,000 deadlocks per thousand commits, which is to say almost every transaction dies and is retried until it gets lucky. Sorting still fixes it. But the shape of that number is the warning: past a certain contention, a retry loop is no longer a safety net, it is the workload. Retrying a transaction that will deadlock again with high probability converts a correctness mechanism into a livelock with extra logging.

Push rows they contend over from 20 up to 200 instead. 20,633 commits, zero deadlocks, no detector runs at all. Spreading the contention is a stronger fix than any ordering discipline, because it removes the wait rather than sequencing it — which is the argument for sharding a hot counter row, and for not funnelling a queue table through a single status row.

Three things the simulation cannot show you, that end real incidents:

The deadlock may not be between two of your transactions. If the process blocking you is an autovacuum worker, the same deadlock_timeout alarm takes a different branch entirely: DeadLockCheck returns DS_BLOCKED_BY_AUTOVACUUM and your backend sends the worker a SIGINT. The worker is the one that logs the error — ERROR: canceling autovacuum task, raised inside its own interrupt handler — so the message carries the vacuum's process id, not yours. Your transaction survives. The vacuum does not, and it restarts from block zero next time, which is why a busy table's autovacuum can appear to run forever and never finish and why, on a large table, the space it already freed is never advertised.

That branch has a carve-out that decides how your incident ends. Read the condition in proc.c: the signal is sent only if (statusFlags & PROC_IS_AUTOVACUUM) && !(statusFlags & PROC_VACUUM_FOR_WRAPAROUND). An anti-wraparound vacuum — the one autovacuum launches when a table's oldest transaction id gets close to the freeze limit — is exempt, and your backend simply keeps waiting for it. So the rule is the opposite of what people learn from the ordinary case: a routine autovacuum yields to you, and the one autovacuum you most need to finish is also the one that will not step aside. If a session appears wedged behind a vacuum that never gets cancelled, check whether that worker is running to stop transaction id wraparound before looking for a bug anywhere else.

Row locks are not in the lock table. A row lock lives in the row's own xmax field on the heap page, which is why max_locks_per_transaction's documentation says flatly: "This is not the number of rows that can be locked; that value is unlimited." What a waiter actually blocks on is a ShareLock on the holder's transaction id, which every transaction holds in ExclusiveLock on itself until it ends. That is why the DETAIL line says "waits for ShareLock on transaction 8814" and never names your table: at the moment of the cycle, the object being contended is a transaction, not a row. The row appears only in the CONTEXT line.

Deadlock is not the same error as a serialization failure. 40P01 is a lock cycle and can happen at any isolation level, including READ COMMITTED. 40001 is a snapshot conflict and only happens at REPEATABLE READ or SERIALIZABLE. Both are transient, both want a retry loop, and the fixes point in opposite directions: consistent lock ordering reduces the first and does nothing for the second. Retrying them correctly is a lesson of its own.

Checking it on a real system

Start with whether you have a deadlock problem at all, in one number that is already being collected:

SELECT datname, deadlocks, xact_commit, xact_rollback,
       round(1000.0 * deadlocks / nullif(xact_commit, 0), 2) AS per_1k_commits,
       stats_reset
FROM pg_stat_database
WHERE datname = current_database();

per_1k_commits is the number to trend, not deadlocks — an absolute count falls whenever throughput falls, which is exactly what happens when you raise deadlock_timeout, and it will look like an improvement.

To find the cycles, turn on the logging that the error already wants you to read. The client only ever gets the process ids; the queries go to the server log:

ALTER SYSTEM SET log_lock_waits = on;      -- off by default
ALTER SYSTEM SET log_min_duration_statement = '250ms';
SELECT pg_reload_conf();

log_lock_waits is the important one and it costs nothing when nothing is waiting, because it fires on the same timer as the deadlock check. That coupling is worth stating plainly: raising deadlock_timeout to 10 s also means no lock wait shorter than 10 s is ever logged. The setting people reach for to quieten deadlocks is the same setting that hides the waits which would have explained them.

With it on you get four messages, and each says something different:

  • process N still waiting for ExclusiveLock on tuple (14,3) ... after 1000.121 ms — a wait passed the timeout and there was no cycle.
  • process N acquired ExclusiveLock ... after 3210.5 ms — the same wait, resolved.
  • process N avoided deadlock ... by rearranging queue order after 1000.2 ms — a soft deadlock. Real, resolved, invisible without this setting.
  • process N detected deadlock while waiting for ... after 1000.4 ms — followed by the ERROR.

While an incident is live, the blocking chain is one function call. Do not write the recursive pg_locks self-join people paste around; pg_blocking_pids already walks it:

SELECT a.pid, pg_blocking_pids(a.pid) AS blocked_by,
       a.state, a.wait_event_type, a.wait_event,
       now() - a.state_change AS waiting_for,
       left(a.query, 70) AS query
FROM pg_stat_activity a
WHERE cardinality(pg_blocking_pids(a.pid)) > 0
ORDER BY waiting_for DESC;

A wait_event_type of Lock with wait_event = transactionid is a row lock. tuple is the queue in front of a row lock. relation is a table-level lock, which in a deadlock almost always means a migration ran against production. If pg_blocking_pids shows a set of rows that point at each other, you are looking at a live cycle that nobody has noticed yet, and you now know exactly how long it will take somebody to: one deadlock_timeout from whenever the last of them blocked.

Finally, the retry loop, because with deadlocks you will need one. Retry on SQLSTATE 40P01 from the outermost level, not from a savepoint. ROLLBACK TO SAVEPOINT undoes only what happened after the savepoint: the transaction keeps its transaction id, keeps every lock it took before that point, and is still the thing the other member of the cycle is blocked on. Retrying from inside it re-enters the same conflict holding the same locks. Only ending the top-level transaction releases them all. Cap the attempts, and jitter the backoff: two clients that deadlocked together and retry after the same fixed delay will deadlock together again.

Your service logs 40 deadlock detected errors an hour. You raise deadlock_timeout from 1s to 10s. The next hour logs 6. What most likely happened?

Next, the mechanism every one of these cycles is built out of: row locks, their four modes, and what SKIP LOCKED really skips.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.