PostgreSQL
PostgreSQL — ERROR: deadlock detected
Written and reviewed by Sahil Srivastav
ERROR: deadlock detected
DETAIL: Process 18422 waits for ShareLock on transaction 9912; blocked by process 18447.
Process 18447 waits for ShareLock on transaction 9908; blocked by process 18422.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,14) in relation "accounts"What this error actually means
PostgreSQL found a cycle in the lock wait graph and broke it by cancelling one transaction. The victim gets this error and is rolled back; the other transaction proceeds. This is the database protecting itself, and unlike an application-level deadlock it resolves automatically after `deadlock_timeout` (one second by default) — so the symptom is periodic failed transactions rather than a permanent hang.
Read the `DETAIL` as a cycle: each process is waiting for a transaction held by the other. The `CONTEXT` line is the most useful part, because it names the relation and the specific tuple being updated when the cycle closed. Two processes updating the same two rows in opposite orders is the whole story in the vast majority of cases.
The subtlety that makes this hard to spot in review: you rarely write the ordering explicitly. `UPDATE accounts SET ... WHERE id IN (7, 3)` locks rows in whatever order the plan produces them, and a concurrent statement with the ids reversed can produce the mirrored order. Nothing in either statement looks wrong.
Causes, most common first
- 1Multi-row updates with no deterministic order. The dominant cause. A statement or transaction touching several rows acquires locks in plan order or argument order. Two concurrent transactions with overlapping sets in different orders close a cycle.
- 2Mirrored two-entity operations. Transfers, swaps, merges, follow/unfollow — anything that updates entity A then entity B. Running A→B and B→A concurrently is a textbook cycle, and it is the operation most likely to be written from each side’s point of view.
- 3Foreign key locks taken implicitly. Inserting a child row takes a lock on the referenced parent row. Two transactions inserting children of different parents while also updating the other parent deadlock through locks neither statement mentions.
- 4Upsert contention on a unique index. `INSERT ... ON CONFLICT` takes and releases index locks as it resolves conflicts. Concurrent upserts of overlapping key sets in different orders deadlock, especially in batch loaders.
- 5Lock escalation mid-transaction. A transaction reads rows without locking, then updates them later. The gap allows another transaction to interleave and acquire the same rows in the opposite order — a read-then-write pattern that is a cycle waiting for the right timing.
When you see it
- A small, steady percentage of transactions fail under concurrency and succeed on retry
- Rate scales with concurrency and with how many rows each transaction touches
- Failures cluster on hot rows — a popular product, a shared counter, a single tenant
- Latency shows a floor around `deadlock_timeout`, because the victim waited that long before detection
- It reproduces only when two mirrored operations run simultaneously
How to diagnose it
Step 1
Get both queries from the server log
The client only ever sees the victim’s side. `log_lock_waits` plus a reasonable `deadlock_timeout` makes PostgreSQL log both statements in the cycle, which is what you need to compare their lock orders.
ALTER SYSTEM SET log_lock_waits = on;
ALTER SYSTEM SET deadlock_timeout = '1s';
SELECT pg_reload_conf();Step 2
Read the CONTEXT for relation and tuple
The `CONTEXT: while updating tuple (0,14) in relation "accounts"` line identifies exactly which table and row closed the cycle. Combined with the two statements, this gives you the pair of resources and the two orders.
Step 3
Count and classify over time
Measure rate rather than reacting to single occurrences. A steady low rate on hot rows is an ordering bug; a sudden spike usually follows a deploy that changed a batch size or added a second update.
SELECT deadlocks, xact_commit, xact_rollback FROM pg_stat_database WHERE datname = current_database();Step 4
Reproduce with mirrored arguments
Two concurrent loops, one running the operation forward and one reversed, on a small set of hot ids. Ordering deadlocks reproduce within seconds under this shape and essentially never under single-direction load.
The fix
Impose a deterministic lock order and apply it everywhere. Sort the ids before locking and take the rows with `SELECT ... FOR UPDATE` in that sorted order at the start of the transaction. Once every transaction agrees on one total order, a cycle is impossible by construction — this is the fix that removes the failure rather than tolerating it.
Lock everything up front rather than escalating mid-transaction. Read-then-update leaves a window for interleaving; acquiring all the row locks you will need, in order, as the first act of the transaction closes it.
Shrink the transaction so fewer rows are held at once. Batch loaders that update 10,000 rows in one transaction deadlock far more than ones that commit in ordered chunks of a few hundred — smaller batches mean smaller windows and fewer overlapping sets.
Keep a bounded retry with jitter, because deadlocks are a legitimate transient outcome under concurrency and the victim is always safe to replay. But treat retry as the safety net after ordering is fixed: a rising deadlock rate masked by retries is latency and lost throughput you are choosing not to see.
For hot single-row counters, consider removing the contention instead of ordering it — an append-only ledger with periodic aggregation has no cycle to create.
-- Lock order depends on plan order: two concurrent calls can mirror
UPDATE accounts SET balance = balance + $2 WHERE id = ANY($1);
-- Deterministic order makes a cycle impossible
BEGIN;
SELECT id FROM accounts
WHERE id = ANY($1)
ORDER BY id
FOR UPDATE; -- all locks taken up front, in one global order
UPDATE accounts SET balance = balance + $2 WHERE id = $3;
UPDATE accounts SET balance = balance - $2 WHERE id = $4;
COMMIT;How to stop it coming back
- Establish one canonical row-lock order (usually primary key ascending) and enforce it in review for every multi-row transaction
- Always `ORDER BY` in `SELECT ... FOR UPDATE`; an unordered locking read is an ordering bug waiting for traffic
- Track `pg_stat_database.deadlocks` as a metric and alarm on rate, so retries cannot hide a regression
- Test concurrency with mirrored arguments — the only shape that catches this class of bug
- Prefer append-only writes over in-place updates of hot shared rows
FAQ
Should I just retry on deadlock?
Retry is correct and necessary — the victim was rolled back cleanly, so replay is safe. It is not sufficient. A retry converts the failure into extra latency and wasted work, and it hides a rate that will keep climbing with concurrency. Fix the ordering and keep the retry as a net.
Does a higher isolation level help?
No. Serializable can add a different failure — `could not serialize access due to concurrent update` — but deadlocks are about lock acquisition order and occur at every isolation level. Higher isolation typically means more aborts, not fewer.
Why do I only see one of the two queries?
The client receives only the victim’s error. Both statements are in the server log when `log_lock_waits` is enabled — and you need both, since the bug is the relationship between their orders rather than anything wrong with either one.
Can a single-statement UPDATE deadlock on its own?
Yes. One statement touching multiple rows acquires locks in plan order, and two concurrent executions with overlapping row sets can acquire them in opposite orders. `ORDER BY` in a preceding `FOR UPDATE` is what makes the order deterministic.
Related
Other errors engineers hit next to this one
- Exactly-once claim fails at an external side effect
- Dead-letter queue growing without an alert
- Idempotency key reused with a different request body
- Read-after-write returned stale data from a replica
- Replication lag: replica served stale data after a write
- FATAL ERROR: Reached heap limit Allocation failed
- Unhandled promise rejection crashes the process
- ECONNRESET: socket hang up on a reused connection