Deadlocks are a lock ordering bug, and lock ordering is a design decision

> $ stat metadata
Date: 2026.09.21
Time: 5 min read
Tags: [deadlocks, concurrency, locking, postgresql, transactions, java]

A payment service transfers money between accounts. It locks the source account, locks the destination account, moves the balance, commits. Straightforward, correct, and it has run for two years.

Then two customers pay each other at the same moment. One transfer locks account 7 and reaches for account 12. The other locks account 12 and reaches for account 7. Neither will ever get what it is waiting for.

Postgres notices after a second, kills one of them with ERROR: deadlock detected, and the other completes. The failed request returns a 500 to a customer who did nothing wrong.

The two transfers were individually correct. What produced the deadlock is that nothing anywhere in the code decided which account gets locked first.

Four conditions, one of which is yours

The textbook lists four necessary conditions for deadlock: mutual exclusion, hold and wait, no preemption, and circular wait. All four must hold simultaneously.

Three of them are properties of the system you are using. A database lock is exclusive because that is what a lock is. Transactions hold locks while acquiring more because that is how atomicity works. The engine will not forcibly take a lock away mid-transaction.

Circular wait is the one your code decides, and it is decided by the order in which you acquire things.

flowchart LR
    T1["Transaction A<br/>holds row 7"] -->|"wants row 12"| T2["Transaction B<br/>holds row 12"]
    T2 -->|"wants row 7"| T1

    style T1 stroke:#ef4444,stroke-width:3px,color:#fff
    style T2 stroke:#ef4444,stroke-width:3px,color:#fff

Break that arrow and no combination of the other three conditions produces a deadlock. That is the whole prevention strategy, and it is why I think of deadlocks as a design bug rather than a concurrency accident.

Sorting is the fix

If every transaction acquires locks in the same order, a cycle cannot form. The simplest total order available is usually the primary key.

public void transfer(long fromAccount, long toAccount, BigDecimal amount) {
    // Lock in a fixed order regardless of which account is the source.
    // Two concurrent transfers in opposite directions now queue rather
    // than deadlock, because both want the lower id first.
    long first = Math.min(fromAccount, toAccount);
    long second = Math.max(fromAccount, toAccount);

    jdbc.query("SELECT id FROM accounts WHERE id = ? FOR UPDATE", first);
    jdbc.query("SELECT id FROM accounts WHERE id = ? FOR UPDATE", second);

    debit(fromAccount, amount);
    credit(toAccount, amount);
}

Four lines and the class of bug is gone. The business logic still knows which account is the source, the locking order is simply independent of that.

For a batch operation the same principle applies to the whole set:

List<Long> ids = new ArrayList<>(orderIds);
Collections.sort(ids);            // total order across all callers
for (Long id : ids) {
    lockAndProcess(id);
}

The failure mode this prevents is subtle in batch code, because a job that processes ids in the order they arrived from a queue has effectively random ordering, and two workers handling overlapping batches will collide sooner or later.

A single statement that locks multiple rows can deadlock too, because the engine acquires locks in the order it visits rows, which depends on the query plan. SELECT ... WHERE id IN (7, 12) FOR UPDATE gives you no ordering guarantee unless you add ORDER BY id, and even then a different plan can change the access path. Locking one row at a time in sorted order is more verbose and more predictable.

The ones that do not look like locks

Explicit FOR UPDATE is the easy case because the locking is visible. Most deadlocks I have seen come from locks nobody wrote.

A foreign key check takes a lock on the parent row, so inserting a child row locks the parent. Two transactions inserting children of different parents while also updating each other’s parents will deadlock, and neither statement contains the word lock.

An index update takes locks on index pages, so two transactions updating different rows can contend on the same index entry when the indexed values are adjacent.

Under REPEATABLE READ in InnoDB, a range locking read takes gap locks covering index gaps, which means two transactions inserting into the same gap can deadlock while inserting entirely different rows. That is the mechanism that prevents phantoms, working exactly as designed, and it makes the set of things you are locking wider than the set of rows you named.

ON DELETE CASCADE acquires locks on child rows in an order determined by the engine, which is the sort of thing that makes cascades worth more suspicion than the foreign keys themselves.

Detection is a feature, and application deadlocks do not have it

A database deadlock is the good outcome. The engine runs a detector, finds the cycle, picks a victim based on which transaction has done the least work, and aborts it with a specific error. Postgres does this after deadlock_timeout, one second by default. InnoDB detects immediately.

That means a database deadlock is loud, fast, and retryable.

@Retryable(
    retryFor = DeadlockLoserDataAccessException.class,
    maxAttempts = 3,
    backoff = @Backoff(delay = 50, multiplier = 2, random = true)   // jitter
)
public void transfer(long from, long to, BigDecimal amount) { ... }

Retry is correct here in a way it usually is not, because the victim transaction was rolled back completely. There is no partial state to reconcile. The jitter matters for the same reason it always does: two transactions that deadlocked and both retry after exactly 50ms will deadlock again.

An application level deadlock between two mutexes has no detector at all. Two threads each holding a lock the other wants simply stop, forever, with no error and no timeout. The thread pool drains as more requests arrive and block behind them. The service stops responding while every metric it exports looks normal, because nothing failed.

// Thread 1: synchronized (a) { synchronized (b) { } }
// Thread 2: synchronized (b) { synchronized (a) { } }
// Nobody detects this. Nothing times out. It just stops.

jstack will show it, and finding it requires somebody to suspect it and take a thread dump, which is a much worse operational property than an error code. Using tryLock with a timeout instead of unconditional acquisition converts a permanent hang into a failure you can observe.

What I would do

Sort identifiers before locking them. It is the single highest value change and it costs one line.

Log deadlocks with the full detail the engine gives you. log_lock_waits = on in Postgres and SHOW ENGINE INNODB STATUS in MySQL both report the two statements involved, which turns diagnosis from guesswork into reading. Deadlocks are usually rare enough that logging every one is free.

Retry on the specific deadlock error rather than on generic failures, with jitter, and cap the attempts.

And treat a rising deadlock rate as a design signal rather than a tuning problem. Deadlocks scale with transaction duration and with how many rows each one touches, so an increasing rate usually means transactions are getting longer or wider, and both of those have causes worth finding independently.

Frequently Asked Questions

What causes a database deadlock?
Two transactions acquiring the same set of locks in different orders. If transaction A locks row 1 then waits for row 2, while transaction B locks row 2 then waits for row 1, neither can proceed because each holds what the other needs. The database detects the cycle and aborts one of them with a deadlock error. The underlying cause is almost never the specific rows involved, it is that nothing in the code establishes a consistent order for acquiring them.
How do you prevent deadlocks?
Impose a total order on lock acquisition so a cycle cannot form. In practice that means sorting the identifiers you intend to lock before you lock any of them, so every transaction touching accounts 7 and 12 always locks 7 first regardless of which one the business logic considers the source. Keeping transactions short and acquiring all locks up front rather than progressively also reduces the window in which two transactions can interleave badly.
What is the difference between a deadlock and a lock timeout?
A deadlock is a cycle where two or more transactions each hold a lock the other needs, so waiting can never resolve it and the database aborts one immediately once it detects the cycle. A lock timeout is a single transaction waiting too long for a lock held by another that is simply slow, which would eventually succeed if given enough time. Deadlocks produce a specific error code and should be retried, while timeouts usually indicate a long-running transaction that needs investigating rather than retrying.

[ RELATED_LOGS ]

TTFB: -- ms LOAD: -- s PAYLOAD: -- kb