SysDesignPrep.com
Interactive concepts

Database deadlocks: an interactive wait-for graph and the lock-ordering fix

A deadlock happens when transactions wait for each other in a circle: T1 holds row A and wants row B, while T2 holds B and wants A. Neither can move, and waiting longer does not help. Databases detect the cycle in a wait-for graph and abort one transaction, which the application must retry. The reliable cure is to make cycles impossible by always taking locks in the same order.

The playground runs money transfers that each lock two account rows, taking turns. Step through what happens, watch the wait-for graph, and turn on locking in id order.

Two transfers run at once, T1 moves money from A to B, T2 moves money from B to A; each locks both account rows before changing them. Taking turns, each locks its first row, then waits for a row another holds: a cycle that no amount of waiting can break. The database detects it, aborts T2 with a deadlock error, and the application has to retry it. Both commit in the end, after 1 deadlock.

  1. 1T1 locks row A
  2. 2T2 locks row B
  3. 3T1 wants row B, held by T2, and waits
  4. 4T2 wants row A, held by T1, and waits
  5. 5Deadlock: T2 waits for T1, which waits for T2. T2 is aborted and its locks released
  6. 6T2 is retried from the start
  7. 7T1 gets row B
  8. 8T2 wants row B, held by T1, and waits
  9. 9T1 commits and releases A and B
  10. 10T2 gets row B
  11. 11T2 locks row A
  12. 12T2 commits and releases B and A
T1AT2B

Who waits for whom after step 5; each circle lists the rows it holds, and red marks a cycle.

Deadlocks here12 of 2 committed
Random transfers25 per 100deadlock, 8 at a time over 5 accounts
Same, in id order0deadlocks in 1,600 transfers

How a deadlock forms, and how it is found

Transferring from A to B locks A then B; transferring from B to A locks B then A. Run together, each takes its first row, then waits for the other’s. Every database keeps track of who waits for whom, and a cycle in that graph is a deadlock. PostgreSQL checks for one after a transaction has waited deadlock_timeout, one second by default, and aborts it with “deadlock detected”; InnoDB checks immediately and rolls back the transaction that has changed the fewest rows. The cycle can be longer than two: three transfers A to B, B to C and C to A deadlock the same way.

Deadlocks become common under load. In the playground’s random batches, eight concurrent transfers over five accounts deadlock about 25 times in every 100 transfers. Locking in id order brings that to zero.

Preventing them

  • Lock in a consistent order. Sort the rows by id and lock them in that order, ideally in one statement with ORDER BY id FOR UPDATE. Whoever gets the first lock can always get the rest, so waits never form a circle.
  • Keep transactions short and do no network calls or user interaction while holding locks.
  • Retry on deadlock errors. Even careful code meets them, through foreign keys, indexes or triggers that take locks implicitly. Retry the whole transaction, and make it idempotent if it has effects outside the database.
  • Avoid locks altogether where you can: a single UPDATE ... SET balance = balance - 10, or optimistic concurrency with a version check, holds nothing between statements.

Distributed locks across services can deadlock too, and there is no single database to see the whole graph. Systems there rely on lock ordering, timeouts and leases that expire rather than on detection.

A transfer that cannot deadlock with itself

from psycopg import errors

def transfer(conn, src, dst, amount, tries=3):
    for attempt in range(tries):
        try:
            with conn.transaction():
                # lock both rows up front, always in id order
                conn.execute(
                    "SELECT id FROM accounts WHERE id IN (%s, %s) ORDER BY id FOR UPDATE",
                    (src, dst))
                conn.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s", (amount, src))
                conn.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", (amount, dst))
            return
        except errors.DeadlockDetected:          # SQLSTATE 40P01: rerun the whole transaction
            continue
    raise RuntimeError("transfer kept deadlocking")

In a system design interview

Bring it up when two rows change in one transaction: “A transfer locks both accounts with SELECT FOR UPDATE ordered by account id, so concurrent transfers in opposite directions queue instead of deadlocking. Lock timeouts are short, and deadlock or serialization errors are retried with the same idempotency key, so a retry never moves money twice.” Expect the follow-ups: hot accounts that serialize every transfer, and what changes when the two accounts live on different shards.

Read the guide to transactions and isolation or distributed locks and leases, see isolation levels, or watch a strong candidate design a payment system.

Questions people ask

What is a database deadlock?

A situation where two or more transactions each hold a lock the other needs and wait for each other in a cycle, so none can proceed. The classic case: one transaction locks row A then wants row B, while another locks B then wants A.

How do databases detect deadlocks?

They track which transaction waits for which in a wait-for graph and look for cycles. PostgreSQL runs the check after a transaction has waited deadlock_timeout, one second by default; InnoDB checks as soon as a transaction starts waiting. When a cycle is found, one transaction is aborted so the others can continue.

How do you prevent deadlocks?

Acquire locks in a consistent order, for example by sorting the rows by primary key and locking them with one SELECT ... ORDER BY id FOR UPDATE. Also keep transactions short, avoid holding locks during slow work, and prefer single-statement updates or optimistic concurrency where possible.

What should an application do when it gets a deadlock error?

Retry the whole transaction from the beginning, usually after a short random delay. In PostgreSQL the error is SQLSTATE 40P01. Because the transaction may be retried, any effects outside the database, such as sending a message, should be idempotent or happen after commit.

What are the four conditions for a deadlock?

Mutual exclusion, where a resource can be held by only one party; hold and wait, where a party holds one resource while waiting for another; no preemption, where resources cannot be taken away; and circular wait. Removing any one prevents deadlock, and lock ordering removes circular wait.

Is a deadlock the same as lock contention?

No. Under contention, transactions wait for a lock that will eventually be released, which slows them down. In a deadlock the waits form a cycle, so no lock will ever be released without the database aborting one of the transactions.

Can distributed systems deadlock?

Yes, when services hold locks in different stores or lock services and wait on each other. No single database sees the whole wait-for graph, so distributed systems rely on consistent lock ordering, timeouts and leases that expire rather than on cycle detection.