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.
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
What you’ll learn · Before you start
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
On this page
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
A deadlock between two transfers
Text description of the diagram
The figure shows two transfers and the two account rows they need.
- Transfer 1, in which Asha sends 100 to Ravi, updates Asha's row first and holds its lock.
- Transfer 2, in which Ravi sends 200 to Asha, updates Ravi's row first and holds its lock.
- Transfer 1 now needs Ravi's row and waits for transfer 2's lock.
- 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
| Criterion | PostgreSQL 18 | MySQL 8.4 | SQLite |
|---|---|---|---|
| When a deadlock is found | After deadlock_timeout (1 s) of waiting | At once, when the lock is requested | Never waited for: SQLite refuses the request |
| Error for the victim | deadlock detected, SQLSTATE 40P01 | Error 1213, SQLSTATE 40001 | SQLITE_BUSY: "database is locked" |
| What is rolled back | The whole transaction | The whole transaction | Nothing: the transaction stays open until you roll back |
| Ordinary lock wait limit | None (lock_timeout = 0) | innodb_lock_wait_timeout = 50 s | The busy timeout your program sets |
| Serialization failure | SQLSTATE 40001 | No separate error: conflicts become waits or deadlocks | SQLITE_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 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.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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
-
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.
-
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?
-
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.
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
- PostgreSQL documentation: Explicit Locking (Deadlocks) (The PostgreSQL Global Development Group)
- PostgreSQL documentation: PostgreSQL Error Codes (The PostgreSQL Global Development Group)
- PostgreSQL documentation: Lock Management (deadlock_timeout) (The PostgreSQL Global Development Group)
- PostgreSQL documentation: Client Connection Defaults (lock_timeout) (The PostgreSQL Global Development Group)
- PostgreSQL documentation: Error Reporting and Logging (log_lock_waits) (The PostgreSQL Global Development Group)
- PostgreSQL documentation: Transaction Isolation (serialization failure handling) (The PostgreSQL Global Development Group)
- MySQL 8.4 Reference Manual: Deadlocks in InnoDB (Oracle Corporation)
- MySQL 8.4 Reference Manual: Deadlock Detection (Oracle Corporation)
- MySQL 8.4 Reference Manual: InnoDB Error Handling (Oracle Corporation)
- MySQL 8.4 Reference Manual: InnoDB Locking (gap and next-key locks) (Oracle Corporation)
- MySQL 8.4 Reference Manual: InnoDB Startup Options and System Variables (innodb_lock_wait_timeout) (Oracle Corporation)
- SQLite: File Locking And Concurrency In SQLite Version 3 (SQLite)
- SQLite: Register A Callback To Handle SQLITE_BUSY Errors (SQLite)
- sqlite3: DB-API 2.0 interface for SQLite databases (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress