Practical guide

How to solve a database deadlock

A deadlock is not a random database error. Find the reversed lock order, fix it, and only then add a bounded retry.

25 minutes · PostgreSQL

First, the short version

Two transactions are waiting for each other

A deadlock occurs when two transactions hold different locks and each waits for the other lock. PostgreSQL detects the cycle, aborts one transaction, and returns SQLSTATE 40P01.

Rolling back the victim releases its locks, but the business operation remains incomplete. The durable fix is predictable lock ordering and a short transaction; bounded retries only protect against unavoidable concurrency.

Get ready

What you need

You need both sides of the conflict. A stack trace from only the aborted transaction is usually not enough.

  • A PostgreSQL log containing deadlock details, SQLSTATE, and timestamps for the affected queries.
  • An application log with a correlation ID, use-case name, and safely recorded record identifiers.
  • Access to pg_stat_activity and pg_locks for observing blocking without changing the database.
  • An integration scenario that can run two competing transactions against the same data.

Steps 1 to 3

Find the cycle and remove its cause

First reconstruct query order in both transactions. Then make locking consistent and add precisely scoped recovery.

1. Confirm the deadlock and reconstruct ordering

  1. Distinguish SQLSTATE 40P01 deadlock_detected from ordinary lock waiting, a timeout, and a lost connection. Each failure needs a different response.
  2. Find the queries and locks from both processes in the database log. Match them to specific use cases in the application log.
  3. Write down the order of reads and writes. A typical cycle is: transaction A locks row 1 and requests 2, while transaction B locks 2 and requests 1.
  4. Do not log sensitive parameter values. A correlation ID and internal identifiers are enough to connect the events.
SELECT pid, wait_event_type, wait_event, state, query FROM pg_stat_activity WHERE datname = current_database();
Official PostgreSQL pg_stat_activity documentation

2. Always lock data in the same order

  1. Define one order shared by every use case, such as resource type first and primary key ascending second.
  2. When locking multiple rows, sort the identifiers first. The SELECT FOR UPDATE query must enforce the same order.
  3. Lock only data needed for the invariant and write immediately. HTTP calls, email, files, and long computation do not belong in the transaction.
  4. Verify indexes used by UPDATE and SELECT FOR UPDATE conditions. Unnecessarily broad scans hold locks longer and widen the conflict window.
SELECT id FROM account WHERE id IN (:ids) ORDER BY id FOR UPDATE;
Official PostgreSQL deadlock documentation

3. Retry only recognised safe conflicts

  1. Catch only a recognised 40P01 deadlock and optionally 40001 serialization_failure when the use case uses an isolation level that requires retries.
  2. After rollback, create a new EntityManager and repeat the entire transaction, including every read and check. Do not continue from a middle query.
  3. Set a small limit, such as three attempts, with short random jitter between them. Return a controlled failure and record a metric after exhaustion.
  4. Do not retry every database exception automatically. A syntax error, constraint violation, or lost connection is not a confirmed deadlock.
SQLSTATE 40P01 / 40001 → rollback → jitter → retry the whole transaction
Official PostgreSQL list of SQLSTATE codes

Step 4

Verify the fix under concurrency

A sequential test cannot cause a deadlock. You need two real connections, controlled step interleaving, and a check of the final invariant.

  1. Reproduce the original reversed order

    Lock the same rows in opposite order through two connections and verify that the test captures SQLSTATE 40P01 before the fix.

    php bin/phpunit --filter Deadlock
  2. Run many concurrent attempts after the fix

    Both use cases must follow the same order. Observe the deadlock count, wait duration, and the number of retries used.

  3. Verify data after a retry

    The operation must happen exactly once from the business perspective. Check balances, record counts, and unique keys.

If something goes wrong

Common problems

The deadlock still returns after adding ORDER BY

Another use case probably locks the same resources in a different order or already holds another lock. Compare complete transactions, not just one query.

A retry fails on a closed EntityManager

Do not reuse the original Unit of Work after rollback. Start every new attempt with a new EntityManager and load all data again.

The application retries permanent errors too

Filter by the specific SQLSTATE or a precisely mapped Doctrine exception. Limit attempts and pass every other error to the caller immediately.

An external request was sent twice after a retry

External I/O occurred inside the retried transaction. Move it after commit or use a transactional outbox and idempotent processing.

Done

Lock acquisition now has a predictable order.

PostgreSQL now receives short transactions with consistent lock ordering, and the application retries only recognised concurrency conflicts. Keep monitoring deadlock count as a production metric.

Request a call

I will call you on the next working day between 9:00 and 17:00.

You can also call me directly.

+420 605 181 728

Leave your phone number and send a callback request.

By sending, you agree to processing your data in order to handle your request.