Skip to content
ohsudo, Home
Databases

PostgreSQL "deadlock detected": read the message, then lock in the same order

Fix PostgreSQL "ERROR: deadlock detected": read which processes and rows were involved, lock rows in a consistent order, and retry on SQLSTATE 40P01.

Intermediate4 min readApplies to PostgreSQL 13–18

by OhSudo ··

Part of a learning path:SQL and PostgreSQL, step by step

ERROR: deadlock detected
DETAIL: Process 4120 waits for ShareLock on transaction 795; blocked by process 4118.
Process 4118 waits for ShareLock on transaction 796; blocked by process 4120.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,2) in relation "accounts"

Two transactions each held a lock the other needed, so neither could ever finish. PostgreSQL noticed, and ended one of them, yours, so the other could go on. Nothing is damaged: your transaction was rolled back, and running it again usually works.

So a single deadlock isn’t an outage. Frequent ones mean your code locks the same rows in different orders, and that’s what to fix.

  1. Transaction 1holds one row, waits for the other
  2. Transaction 2holds the other, waits for the first
  3. PostgreSQLends one: deadlock detected(breaks here)
Each transaction held a lock the other needed. PostgreSQL ended one of them so the other could finish; frequent deadlocks mean your code locks the same rows in different orders.

TL;DR

  • Retry the whole transaction when you get SQLSTATE 40P01.
  • Lock rows in the same order everywhere, for example by id.
  • Keep transactions short, with no waiting inside them.

1. How it happens

Two transfers run at the same moment, between the same two accounts, in opposite directions:

Time Transaction A Transaction B
1 UPDATE accounts ... WHERE id = 1 locks row 1 UPDATE accounts ... WHERE id = 2 locks row 2
2 UPDATE accounts ... WHERE id = 2 waits for B
3 UPDATE accounts ... WHERE id = 1 waits for A: deadlock

Neither will ever let go. After deadlock_timeout, one second by default, PostgreSQL checks for such a circle, finds it, and cancels one side.

2. Read the message

DETAIL names the two processes, and CONTEXT names the table and the row: tuple (0,2) in accounts. The server’s log has the full statements, as the HINT says:

Terminal window
sudo grep -A 8 'deadlock detected' /var/log/postgresql/postgresql-18-main.log | tail -n 20

On RHEL, the log is under /var/lib/pgsql/18/data/log/, and in Docker, it’s in docker logs. Look for two statements on the same table, in different orders, from two places in your code.

3. Lock in the same order

If both transfers lock the lower id first, the second one simply waits, and no circle forms:

BEGIN;
SELECT id FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

SELECT ... ORDER BY id FOR UPDATE takes all the locks first, in a fixed order. The same rule applies to batch updates: an UPDATE with no order locks rows in whatever order it finds them, and two such batches can deadlock.

4. Other common causes

Long transactions. The longer you hold locks, the more chances of a collision. Don’t call external services, or wait for a user, inside a transaction.

Foreign keys. Inserting a child row briefly locks its parent row in a shared mode. Two transactions each inserting children and then updating the other’s parent can deadlock. Order the work the same way, or update the parent first.

No index on a foreign key column. Deleting or updating a parent then has to scan the child table, which makes these lock waits longer, and collisions more likely. Index the referencing columns.

5. Retry in the application

Even with careful ordering, deadlocks can happen under load, and retrying is the expected way to handle them. The error has its own code, 40P01:

from psycopg import errors
for attempt in range(3):
try:
with conn.transaction():
transfer(conn, from_id, to_id, amount)
break
except errors.DeadlockDetected:
if attempt == 2:
raise

Retry the whole transaction, not only the last statement, since all of it was rolled back.

6. See lock waits as they happen

Turn on logging of slow lock waits, which needs only a reload:

ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();

Any wait longer than deadlock_timeout then goes to the log, with the statements. To see who blocks whom right now:

SELECT pid, pg_blocking_pids(pid) AS blocked_by, state, wait_event_type, query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;

A blocker in the state idle in transaction is a transaction someone didn’t commit. Setting idle_in_transaction_session_timeout ends those automatically.

Warning

Don’t raise deadlock_timeout to make these errors rarer: a real deadlock never resolves by itself, so you’d only wait longer for the same outcome. And avoid LOCK TABLE as a quick fix. It removes deadlocks by making every transaction wait in line, which slows everything down.

Shell, we have a problem!

Similar, but different: could not serialize access due to concurrent update is SQLSTATE 40001, from REPEATABLE READ or SERIALIZABLE isolation: also retry it. canceling statement due to lock timeout comes from a lock_timeout you set. duplicate key value violates unique constraint is a conflict on data, not on locks. On MySQL, see Deadlock found when trying to get lock and Lock wait timeout exceeded. On SQLite, see database is locked.

Found this useful? Share it.

Found a mistake? Let us know at contact@ohsudo.com.

Articles

Related articles