- English
- English
Appearance
Have you ever seen an error like this appear in your application logs during peak traffic?
ERROR: deadlock detected
Detail: Process 1234 waits for RowShareLock on transaction 5678;
blocked by process 5678.
Process 5678 waits for RowShareLock on transaction 1234;
blocked by process 1234.
Hint: See server log for query details.What makes it annoying: this error often appears without any code change. The code has been running smoothly for a long time, then suddenly some requests fail during busy hours. This article explains what is really happening, how the database handles it, and how to prevent it.
To follow this article, you only need two basic concepts:
COMMIT) or are canceled together (ROLLBACK).This per-row nature is the root of the problem. One transaction can hold locks on several rows step by step, one after another, as its statements execute.
A deadlock happens when two transactions (or more) wait for locks held by each other, so none of them can move forward.
The easiest analogy: a four-way gridlock intersection where four cars from four directions enter at the same time and each tries to go first. Car A waits for car B to pass, car B waits for car C, and so on. Nobody moves, and everyone waits forever.
Unlike a normal queue, a deadlock cannot be solved by waiting. One side has to give in.
Let us build a deadlock from zero. There are two accounts in the accounts table: ACC-001 and ACC-002.
ACC-001 to ACC-002. Its statement order: reduce the balance of ACC-001 first, then add to the balance of ACC-002.ACC-002 to ACC-001. Its statement order: reduce the balance of ACC-002 first, then add to the balance of ACC-001.Both transactions are valid for the business and valid in SQL. The problem is only the order of the rows they lock:
-- Transaction 1
BEGIN;
UPDATE accounts SET balance = balance - 100000 WHERE account_id = 'ACC-001'; -- lock ACC-001
UPDATE accounts SET balance = balance + 100000 WHERE account_id = 'ACC-002'; -- needs lock ACC-002
-- Transaction 2 (runs at almost the same time)
BEGIN;
UPDATE accounts SET balance = balance - 50000 WHERE account_id = 'ACC-002'; -- lock ACC-002
UPDATE accounts SET balance = balance + 50000 WHERE account_id = 'ACC-001'; -- needs lock ACC-001Here is the timeline of events, step by step:
Reading the timeline above:
ACC-001 when its first UPDATE executes.ACC-002.ACC-002. That row is still held by Transaction 2, so Transaction 1 queues.ACC-001. That row is held by Transaction 1, which happens to be queueing while waiting for Transaction 2.Waiting forever is clearly not an option. Modern relational databases have deadlock detection that runs automatically:
ROLLBACK on that transaction, and releases all of its locks.deadlock detected error. The other transaction is now free to continue and finish successfully.Because the rollback is clean (all victim changes are canceled since BEGIN), the transaction that hit the deadlock can be retried right away by the application. The second attempt usually succeeds, because the other transaction has already finished and its locks are released.
Mental Model
A deadlock is a condition the database handles by itself, not a permanent hang. The practical impact on the application: some requests fail with an error that can be retried. A new problem only appears when the application does not handle that error and treats it as a permanent failure.
Deadlocks can be prevented because their cause is always one pattern: two transactions locking the same rows in a different order. The solutions point in that direction too.
Instead of locking rows one by one as the statements execute, lock all rows you will touch in a single query at the start, with a fixed order:
BEGIN;
-- Lock both accounts at once, always in account_id order
SELECT balance
FROM accounts
WHERE account_id IN ('ACC-001', 'ACC-002')
ORDER BY account_id
FOR UPDATE;
UPDATE accounts SET balance = balance - 100000 WHERE account_id = 'ACC-001';
UPDATE accounts SET balance = balance + 100000 WHERE account_id = 'ACC-002';
COMMIT;With this pattern, both transactions lock ACC-001 first. The one that arrives second queues at ACC-001 from the start, and never gets to hold ACC-002 in the reversed pattern. The waiting circle cannot form, because the lock order taken by every transaction is always the same.
The longer a transaction holds its locks, the bigger the time window for other transactions to enter and form a conflicting waiting pattern. Do not keep a transaction open while waiting for a slow process in the application (an HTTP call to another service, file processing), and do not put unnecessary operations inside the transaction block.
For systems with high concurrency, deadlocks can still happen even with a clean design. Handle the error explicitly and repeat the transaction:
async function executeWithRetry(fn, maxAttempts = 3) {
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
// Only retry deadlock errors, let other errors bubble up
if (error.code === "40P01") {
lastError = error;
// Simple backoff before the next attempt
await new Promise((resolve) => setTimeout(resolve, 50 * attempt));
continue;
}
throw error;
}
}
throw lastError;
}The error code 40P01 is the SQLSTATE code for a deadlock in PostgreSQL. The rollback from a deadlock is clean, so the retry starts from the beginning with consistent data.
Deadlocks do not only happen in money transfer cases. The same pattern often shows up in batch jobs, and this is the case you will meet most often at work.
Imagine two workers processing invoice updates from a queue:
1, then 2, then 3.3, then 2, then 1.No money moves, and there is no bug in either worker's code, but they can still wait on each other: Job A holds the lock on invoice 1 while wanting to update 3, and Job B holds the lock on invoice 3 while wanting to update 1.
The cause is identical to the transfer case: the same dataset, a different lock order. The solution is the same too:
ORDER BY id.ACC-001 and ACC-002.deadlock detected error. The rolled-back transaction can be retried.SELECT ... FOR UPDATE with an ORDER BY, keep transactions short, and catch the deadlock error to retry it.