HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 562 min read

Database Deadlocks & Lock Ordering

debug one with me

Databasesdatabases / concurrency

The symptom

Your payments service throws this ~15 times a day, only in production, only at peak:

ERROR: deadlock detected
DETAIL: Process 4821 waits for ShareLock on transaction 99120;
        blocked by process 4977.
        Process 4977 waits for ShareLock on transaction 99118;
        blocked by process 4821.
HINT: See server log for query details.

It never reproduces locally. Retrying the request works. Tempting conclusion: "flaky DB, add a retry, move on."

Step 1 — read what it actually says

Two transactions each hold a row lock the other one wants. Postgres doesn't hang forever: after deadlock_timeout (default 1s) it detects the cycle, kills the cheaper victim, and the other one commits. So it's not corruption or a bug in Postgres — it's your code asking for locks in two different orders.

Step 2 — find the two paths

The transfer endpoint:

BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = $from;  -- locks row $from
UPDATE accounts SET balance = balance + 50 WHERE id = $to;    -- locks row $to
COMMIT;

Now Ali sends money to Sara while Sara sends money to Ali, at the same second:

T1: lock A ... wants B
T2: lock B ... wants A     ← cycle

That's it. Nothing exotic. Two rows, two transactions, opposite order.

Step 3 — the concept: locks are held to COMMIT

An UPDATE takes a row lock and keeps it until the transaction ends — not until the statement ends. So every extra statement between the first UPDATE and COMMIT widens the window. A cycle needs only two transactions touching the same rows in different orders.

Step 4 — the fix: a global lock order

Pick any total order on the rows and always take locks in it. Sorting by primary key is free and works:

lo, hi = sorted([from_id, to_id])
cur.execute("SELECT id FROM accounts WHERE id IN (%s,%s) ORDER BY id FOR UPDATE", (lo, hi))
# now both UPDATEs, in any order — the locks are already held

Both concurrent transfers now grab the lower id first. One waits, one proceeds. No cycle, ever.

Where people actually get bitten

  • Multi-row UPDATE ... WHERE status = 'pending' — the lock order is the plan's row order, which can flip between an index scan and a seq scan. Add ORDER BY id and lock explicitly.
  • A batch job doing DELETE ... WHERE id IN (...) with an unsorted list from a Set — sort the list.
  • Foreign keys: inserting a child row takes a lock on the parent. Two transactions inserting children of two parents in opposite order deadlock without a single UPDATE in sight.
  • Long transactions: an HTTP call inside BEGIN...COMMIT turns a 2ms lock window into 400ms. Do I/O outside the transaction.

And still keep the retry

Ordering removes the deadlocks you designed; it can't remove every one. Retry on SQLSTATE 40P01 (Postgres) / error 1213 (MySQL) with a small jitter, max 3 attempts — but retry from the top of the transaction, not from the failed statement. The whole transaction was rolled back; re-running one UPDATE on a dead transaction just errors again.

🧪 Also useful: log_lock_waits = on plus deadlock_timeout = 1s tells you which queries were waiting, not just which pids.

Your exercise

Open two psql sessions. In session 1: BEGIN; UPDATE accounts SET balance=balance WHERE id=1;

In session 2: BEGIN; UPDATE accounts SET balance=balance WHERE id=2;

Then in session 1 update id=2, and in session 2 update id=1. Watch one of them die after ~1 second, and read the DETAIL line.

Then: is a deadlock possible with only ONE row involved? Why or why not?