Reported symptoms
The overnight settlement batch normally finishes by 03:20. On Monday it is still running at 07:40, sixty per cent complete.
The application error tracker records 912 deadlock detected errors
overnight, against a baseline of four a day for eleven months. Every one
was retried automatically and every retry eventually succeeded, so no
work was lost.
Database CPU is 78% overnight against a normal peak of 40. WAL generation has roughly tripled.
No deployment in nine days. The settlement code has not changed in four months. The only change is a capacity increase applied on Friday, doubling the batch worker pool from eight to sixteen.
The team proposes lowering deadlock_timeout so deadlocks are detected
and retried faster.
Evidence provided
$ grep -A5 'deadlock detected' /var/log/postgresql/postgresql-18-main.log | head -72026-08-24 02:14:08.117 UTC [30112] batch@settle ERROR: deadlock detected
2026-08-24 02:14:08.117 UTC [30112] batch@settle DETAIL: Process 30112 waits for ShareLock on transaction 884213; blocked by process 30087.
Process 30087 waits for ShareLock on transaction 884219; blocked by process 30112.
Process 30112: UPDATE accounts SET balance = balance - $1 WHERE id = $2
Process 30087: UPDATE accounts SET balance = balance - $1 WHERE id = $2
2026-08-24 02:14:08.117 UTC [30112] batch@settle HINT: See server log for query details.
2026-08-24 02:14:08.117 UTC [30112] batch@settle CONTEXT: while updating tuple (14892,3) in relation "accounts"Illustrative output
The account ids in the two halves of each cycle are always in opposite order.
The settlement code selects its accounts with:
SELECT id FROM accounts WHERE batch_id = $1
No ORDER BY. It iterates the result in the order returned.
EXPLAIN shows a Bitmap Heap Scan. Four months ago, when the table
was smaller, the same query used an Index Scan.
Historical deadlock counts: eight workers, ~4 a day. Sixteen workers, ~900 a night.
Work the evidence before reading on
- Both statements in every cycle are the same statement with different parameters. What does that tell you about where the bug is?
- The code has not changed in four months and the deadlocks appeared on Friday. What changed?
- Four a day became nine hundred a night when the workers doubled. Is that proportionate?
- Every deadlock was retried and every retry succeeded. Why is the batch still four hours late?
Root cause
The lock order is undefined, and always has been
The job locks accounts in whatever order the query returns them. Without
ORDER BY, that order is not defined — it is whatever the plan produces.
Two workers whose account sets overlap can therefore acquire the same two locks in opposite orders. That is a cycle, and a cycle is a deadlock.
Doubling the workers more than quadrupled the collisions
Collisions require a pair of workers to overlap. Eight workers give 28 pairs; sixteen give 120. Contention within each pair rises too, since each worker’s set is drawn from the same account population.
Four a day to nine hundred a night is entirely consistent with that. The capacity increase did not cause the bug; it moved the system to a point on the curve where an eleven-month-old bug became the dominant cost.
The retries are why the batch is late
Every deadlock aborts a transaction entirely. Everything it had done is discarded, and the retry starts from the beginning — re-reading the same unordered result set, and therefore as likely to deadlock as the original.
That is where the tripled WAL and the elevated CPU come from: the server is doing the work, throwing it away, and doing it again.
Resolution
Reduce the worker pool to eight for immediate relief. This fixes nothing and restores service. Record it as mitigation, not resolution, so that “we reverted the capacity increase” does not become the accepted answer.
The fix is one clause:
SELECT id FROM accounts WHERE batch_id = $1 ORDER BY id FOR UPDATE;
ORDER BY id makes a cycle impossible. A cycle requires one transaction
to hold a higher key while waiting for a lower one; if every transaction
acquires in ascending order, that cannot happen.
FOR UPDATE takes the locks up front in that order, rather than
acquiring them incidentally as each UPDATE executes.
Restore the worker count to sixteen after deploying, and watch the rate. If it does not return to single digits, there is a second ordering path — most likely a different statement in the same transaction touching a different table.
Verification
Deadlock rate at sixteen workers returns to baseline. Measure from the server log, which records both halves of each cycle:
grep -c "deadlock detected" /var/log/postgresql/postgresql-18-main.log
The batch completes within its normal window at sixteen workers.
WAL generation returns to its previous rate — the measurable consequence of transactions no longer being discarded and repeated.
EXPLAIN shows the ORDER BY satisfied by the plan, so the ordering is
enforced rather than assumed.
A deliberate high-concurrency test in staging produces no deadlocks, where the same test against the previous code reproduces them within seconds. This is what distinguishes “the fix worked” from “traffic was lower tonight”.
Prevention
Order every multi-row lock acquisition. ORDER BY <primary key> FOR UPDATE, or sort the batch before writing it. It costs nothing at runtime
and it is a code review item, because no database setting prevents an
ordering bug.
Never depend on result order without ORDER BY. The order comes from
the plan, the plan comes from the statistics, and the statistics change
as the data grows. This is the mechanism that made a seven-month-old
correct-by-accident program incorrect with no deployment.
Alert on the deadlock rate, not on individual deadlocks. A few a day is normal. A rate that rises with traffic is an ordering bug that will become an outage at a traffic level you have not reached yet.
Log and retain the deadlock detail. The server log names both statements; the application only ever sees its own. Note that the logged statements carry their parameter values, which matters on tables with sensitive columns.
Review capacity increases like deployments. Doubling concurrency changed the system’s behaviour and found an eleven-month-old bug in one night.