Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

SQL (PostgreSQL, MySQL, SQLite)  Module 11 – Transactions, isolation and locking

Deadlocks, serialization failures and retries

How deadlocks form and how PostgreSQL, MySQL and SQLite break them, which errors to retry, and how to retry a transaction safely with jitter and keys.

  • Advanced
  • 30 minutes
  • Examples run with sql.js 1.14.2, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 9 of 30 minutes free
Free: How deadlocks form · How each engine breaks a deadlock · SQLite refuses to wait, on a real database · 2 more sections. With a pass: 3 in-depth parts and 3 interview answers, about 21 minutes.

What you will learn

  • Explain how deadlocks happen and how each engine resolves them
  • Write a retry loop that runs a refused transaction again safely
  • Prevent deadlocks with a consistent lock order and short transactions
  • Use an idempotency key so that a repeated request changes nothing

Before you start

A deadlock forms when transactions wait for each other in a circle: every one of them needs a lock that the next one holds, so none of them can ever continue. Two transfers that update the same two accounts in opposite order are the classic case. Databases detect deadlocks and break them by rolling back one transaction, the victim, with an error your code can recognise: PostgreSQL reports SQLSTATE 40P01 after waiting for its deadlock_timeout, one second by default, MySQL’s InnoDB reports error 1213 at once, and SQLite answers SQLITE_BUSY instead of starting a wait that could never end. Serialization failures, SQLSTATE 40001, are the other refusal that a second attempt can cure: the transaction was stopped because committing it could break the rules of its isolation level. Both call for the same answer: roll back, then run the whole transaction again, with a few retries at most and a random pause before each.

How deadlocks form

Transfer 1 holds Asha's row and waits for Ravi's; transfer 2 holds Ravi's row and waits for Asha's: a cycle of waits.Transfer 1Asha sends 100 to RaviTransfer 2Ravi sends 200 to Asharow: Asha's accountrow: Ravi's account1. updates it,holds its lock2. updates it,holds its lock3. waits forthis lock4. waits forthis lock: a cycle

A deadlock between two transfers

Text description of the diagram

The figure shows two transfers and the two account rows they need.

  1. Transfer 1, in which Asha sends 100 to Ravi, updates Asha's row first and holds its lock.
  2. Transfer 2, in which Ravi sends 200 to Asha, updates Ravi's row first and holds its lock.
  3. Transfer 1 now needs Ravi's row and waits for transfer 2's lock.
  4. Transfer 2 now needs Asha's row and waits for transfer 1's lock. Each waits for the other, so neither can ever continue: a deadlock. The database must roll one of them back.

Asha sends 100 to Ravi while Ravi sends 200 to Asha. Each transfer updates its sender’s row first, which locks that row until the transaction ends, then asks for the receiver’s row. Transfer 1 holds Asha’s row and waits for Ravi’s; transfer 2 holds Ravi’s row and waits for Asha’s. Waiting longer cannot help, because each is waiting for the other. Deadlocks need two things: transactions that hold locks while they ask for more, and two transactions that take the same locks in a different order. Most deadlocks in applications come from that second point, and most can be designed away.

How each engine breaks a deadlock

Deadlocks and lock waits in PostgreSQL 18, MySQL 8.4 (InnoDB) and SQLite
Criterion PostgreSQL 18MySQL 8.4SQLite
When a deadlock is found After deadlock_timeout (1 s) of waitingAt once, when the lock is requestedNever waited for: SQLite refuses the request
Error for the victim deadlock detected, SQLSTATE 40P01Error 1213, SQLSTATE 40001SQLITE_BUSY: "database is locked"
What is rolled back The whole transactionThe whole transactionNothing: the transaction stays open until you roll back
Ordinary lock wait limit None (lock_timeout = 0)innodb_lock_wait_timeout = 50 sThe busy timeout your program sets
Serialization failure SQLSTATE 40001No separate error: conflicts become waits or deadlocksSQLITE_BUSY_SNAPSHOT in WAL mode

The victim’s work is lost and the other transaction carries on as if nothing had happened. That is why the application, not the database, decides whether the victim’s work runs again.

SQLite refuses to wait, on a real database

SQLite locks the whole database file rather than rows, so its deadlocks look different: two transactions that both read, then both try to write. This program runs two such transfers in SQLite’s default rollback-journal mode, with a busy timeout of 2 seconds on each connection:

Two transfers that would deadlock, on SQLite Python · refuses_to_wait.py
"""Two transfers that would deadlock, on SQLite in its default rollback-journal mode.

Each transfer reads both balances, then writes. Each connection is willing to wait up to
2 seconds for a lock; SQLite still refuses to wait when waiting could never end.
"""
import os
import sqlite3
import time

DB = "wallet.db"
for suffix in ("", "-journal", "-wal", "-shm"):
    if os.path.exists(DB + suffix):
        os.remove(DB + suffix)

setup = sqlite3.connect(DB, autocommit=True)
setup.execute("CREATE TABLE accounts (id INTEGER PRIMARY KEY, holder TEXT NOT NULL, balance INTEGER NOT NULL)")
setup.execute("INSERT INTO accounts VALUES (1, 'Asha', 1000), (2, 'Ravi', 1000)")
setup.close()

t1 = sqlite3.connect(DB, autocommit=True, timeout=2)  # Asha sends 100 to Ravi
t2 = sqlite3.connect(DB, autocommit=True, timeout=2)  # Ravi sends 200 to Asha
MOVE = "UPDATE accounts SET balance = balance + ? WHERE id = ?"


def took(start):
    s = time.monotonic() - start
    return "at once" if s < 0.5 else f"after waiting about {round(s)} s"


t1.execute("BEGIN")
t2.execute("BEGIN")
t1.execute("SELECT * FROM accounts").fetchall()
t2.execute("SELECT * FROM accounts").fetchall()
print("1. both transfers start and read the balances")

t1.execute(MOVE, (-100, 1))
print("2. transfer 1 writes first and now holds the write lock")

start = time.monotonic()
try:
    t2.execute(MOVE, (-200, 2))
except sqlite3.OperationalError as e:
    print(f"3. transfer 2 tries to write: {e} ({e.sqlite_errorname}), {took(start)}")
    t2.execute("ROLLBACK")
    print("4. transfer 2 rolls back, which releases its read lock")

t1.execute(MOVE, (100, 2))
start = time.monotonic()
t1.execute("COMMIT")
print(f"5. transfer 1 commits, {took(start)}")

t2.execute("BEGIN IMMEDIATE")
t2.execute(MOVE, (-200, 2))
t2.execute(MOVE, (200, 1))
t2.execute("COMMIT")
print("6. transfer 2 runs again from the start and commits")

rows = t1.execute("SELECT holder, balance FROM accounts ORDER BY id").fetchall()
print("   " + ", ".join(f"{h} {b}" for h, b in rows))

Output

1. both transfers start and read the balances
2. transfer 1 writes first and now holds the write lock
3. transfer 2 tries to write: database is locked (SQLITE_BUSY), at once
4. transfer 2 rolls back, which releases its read lock
5. transfer 1 commits, at once
6. transfer 2 runs again from the start and commits
   Asha 1100, Ravi 900

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 refuses_to_wait.py

Transfer 2 was refused at once, although it was willing to wait for 2 seconds. It held a read lock and wanted to write while transfer 1 already held the write lock; transfer 1 would have needed transfer 2’s read lock to be released before it could commit, so a wait could never end. SQLite’s documentation describes this case: rather than call the busy handler, it returns SQLITE_BUSY so that one side gives up its lock. Once transfer 2 rolled back, transfer 1 committed at once, and transfer 2 then ran again from the start, this time with BEGIN IMMEDIATE.

What to do when a transaction is refused

  • Retry the whole transaction: run every statement again from BEGIN, never only the statement that failed.
  • Retry only what can succeed: deadlocks and serialization failures, not constraint violations or syntax errors.
  • Limit the retries: three attempts are usually enough; after that, report the failure.
  • Add jitter: wait a random time before each retry, so that colliding transactions do not collide again.
  • Use an idempotency key: a unique key per request makes a repeated transfer change nothing.
  • Lock in one order: take row locks in the same order, for example by id, everywhere.
  • Keep transactions short: the shorter a lock is held, the less often anyone waits for it.

Retries hide most deadlocks and serialization failures from users, but a retry loop without limits or jitter can turn a short conflict into a storm of repeated work. Serialization failures in particular are a normal part of running at Serializable, and PostgreSQL’s documentation expects every application at that level to be ready to retry.

Check yourself

Check yourself

4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 4 Two transactions both update accounts 1 and 2. When can they deadlock?

    Choose one answer.

    Show the answer to question 1

    Answer: When one updates 1 then 2 and the other updates 2 then 1, at the same time

    A deadlock needs a cycle: each transaction holds one row and waits for the other's. If both take the rows in the same order, the second simply waits for the first and then continues.

  2. Question 2 of 4 A transaction fails with SQLSTATE 40P01 at its third statement. What should the application retry?

    Choose one answer.

    Show the answer to question 2

    Answer: The whole transaction, from BEGIN, after a rollback

    The engine has rolled back the victim's whole transaction, so the first two statements are gone too. Running only the failed statement would apply part of the work. Retry every statement, with a limit and a random pause.

  3. Question 3 of 4 Which of these errors may succeed if the whole transaction is simply run again?

    Choose every answer that is right.

    Show the answer to question 3

    Answer:

    • MySQL error 1213, deadlock found
    • PostgreSQL SQLSTATE 40001, could not serialize access
    • PostgreSQL SQLSTATE 40P01, deadlock detected

    Serialization failures and deadlocks depend on timing, so a second attempt usually succeeds. A unique violation or a syntax error fails the same way every time; retrying only wastes work.

  4. Question 4 of 4 In MySQL, a transaction's second UPDATE fails with error 1205, Lock wait timeout exceeded. Its first UPDATE worked. What happens if the code now sends COMMIT?

    Choose one answer.

    Show the answer to question 4

    Answer: The first UPDATE is committed: InnoDB rolled back only the statement that timed out

    With innodb_rollback_on_timeout off, its default, a lock wait timeout rolls back only the statement, so half the transaction can be committed. A deadlock, error 1213, rolls back the whole transaction. Roll back yourself before any retry.

Reading deadlock reports, and the lock-timeout trap reproduced Pro: opens with a Pro, Premium or Ultimate pass

When a deadlock or a lock timeout happens in production, the error text and what the engine rolled back decide what the code must do. This part takes the reports of both engines apart, reproduces the lock-timeout case that commits half a transaction, and finds deadlocks in places with no second table at all.

In this part:

  • The PostgreSQL and MySQL reports, line by line
  • Lock timeouts: the MySQL trap that commits half a transaction, reproduced
  • Deadlocks without opposite orders: share-lock upgrades and gap locks

about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

A retry wrapper that is safe to run twice, built and tested Pro: opens with a Pro, Premium or Ultimate pass

A retry loop is a few lines of code, and each line matters: what to catch, how long to wait, how often to try and how to make a second run harmless. This part measures the waiting, makes a repeated request harmless and has you write the loop.

In this part:

  • Why retries need jitter, simulated
  • Idempotency keys make a repeated request harmless
  • Where the loop goes, and how long it may take
  • Your turn: write the retry loop
  • A retry loop for serialization failures and deadlocks
    • Results of the sample tests

2 runnable examples (Python) · 1 exercise with sample tests · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Fewer deadlocks by design: lock order and short transactions Pro: opens with a Pro, Premium or Ultimate pass

Retries handle the deadlocks that remain; design decides how many there are. This part removes the transfer deadlock by locking in one order, then works through how lock hold times and timeouts set how often anyone waits.

In this part:

  • Locking rows in id order, reproduced
  • Short transactions and timeouts, with numbers

about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Mid to senior

    A payments service on PostgreSQL logs "deadlock detected" a few times an hour, always in the transfer endpoint, which debits the sender and then credits the receiver. Retries hide it from users, but each one adds a second of latency. Explain the log entry and remove the deadlocks.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    After a retry loop was added for SQLSTATE 40001 and 40P01, a customer was charged twice for one order. The logs show a network timeout on COMMIT, then a successful retry. What happened, and how do you make retries safe?

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    On MySQL 8.4, an order service's transaction inserts an order, then updates stock. Under load some updates fail with error 1205, Lock wait timeout exceeded; the code logs it and commits. Orders now exist without stock being reserved. Explain and fix.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Deadlocks are cycles of lock waits; the engine rolls back a victim with 40P01 (PostgreSQL), error 1213 (MySQL) or SQLITE_BUSY (SQLite).
  • Serialization failures (40001) and deadlocks call for a rollback and a retry of the whole transaction, with a limit and random waits.
  • Locks taken in one order cannot form a cycle, and short transactions wait less.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.