← All posts
Database

Postgres "deadlock detected" or "serialization failure"? Retry the transaction, not the statement

For developers and autonomous AI agents · ~6 min read

Under concurrency, Postgres throws two errors that look scary but are routine: ERROR: deadlock detected (SQLSTATE 40P01) and could not serialize access (40001). Both are transient and retryable — the database is telling you to try again. The catch is that most retry code does it wrong, and doing it wrong corrupts data or loops forever.

Why they happen

Both are the database doing its job. Neither is a bug in your query — they're a signal that two things collided, and one should retry.

The mistake almost everyone makes

Retry the ENTIRE transaction, not just the failed statement. When a transaction aborts, its earlier reads, writes, locks, and snapshot are all gone. Re-running only the last statement is incorrect — you've lost the context it depended on. Exit the failed transaction, back off, then execute the complete transaction function again. And Postgres does NOT auto-retry, on purpose: it can't re-run your application logic with any guarantee of correctness, so the retry loop is yours to own.

The correct retry policy

for attempt in range(max_retries):
    try:
        with conn.transaction():
            # ALL the logic that decides which SQL to run — re-run it whole
            run_transaction()
        break
    except (SerializationFailure, DeadlockDetected):   # 40001 / 40P01
        conn.rollback()
        time.sleep(backoff * 2**attempt + random.uniform(0, jitter))
    # any OTHER error aborts immediately — do not retry
  1. Only retry 40001 and 40P01 (and sometimes 23505 unique_violation). Every other error aborts immediately — retrying a logic error just repeats it.
  2. Make the transaction idempotent. The victim may have fired triggers or written to external systems before the abort; the retry may run more than once.
  3. Roll back (or exit the transaction context) BEFORE sleeping. Sleeping while the transaction is still active holds locks and keeps the connection checked out doing nothing.
  4. Cap the retries + use jitter. Log each retry — a transaction that deadlocks repeatedly is a lock-ordering problem to fix, not to retry harder.

Prevent them (and the pool-exhaustion cousin)

For agents touching a database

An agent that retries only the failed statement, or retries a non-retryable error, corrupts state or loops. diagnose_infra_error returns the database family and the correct handling per SQLSTATE — which codes to retry whole, which to never retry, and when a pool timeout is the real issue:

diagnose_infra_error({error: "deadlock detected 40P01"})
// → { family: "database",
//      fix: "retry the WHOLE transaction (idempotent) with backoff+jitter; fix lock ordering; don't retry the statement alone" }
For AI agents

Machine-readable guide: https://snapback.sh/llms.txt · Tool discovery: https://snapback.sh/.well-known/mcp.json · MCP endpoint: https://api.snapback.sh/mcp. Free tools need no token; pay-per-call diagnosis via x402 on Solana or EVM (~$0.0032 USDC).

Try Snapback freeRead the docs