Database Deadlocks Explained
Introduction¶
A deadlock is a cycle: transaction A holds lock X and wants lock Y; B holds Y and wants X. Neither can proceed. The database detects the cycle and aborts one transaction (the "victim") with a deadlock error; the app should retry it. Frequent deadlocks are an application design problem.
Detection and resolution¶
- PostgreSQL — checks for a cycle after a lock wait exceeds
deadlock_timeout(default 1s). Victim getsERROR: deadlock detected/ SQLSTATE40P01. - MySQL/InnoDB — maintains a wait-for graph, detects immediately, rolls back
the transaction that has done the least work. Error 1213 / SQLSTATE
40001. (innodb_deadlock_detect = OFFdisables it in favour ofinnodb_lock_wait_timeout— only for extreme-concurrency workloads.)
The victim's transaction is fully rolled back. Your code must catch the deadlock error and retry (with a little backoff, a few times).
Reading the log¶
PostgreSQL¶
ERROR: deadlock detected
DETAIL: Process 123 waits for ShareLock on transaction 456; blocked by process 789.
Process 789 waits for ShareLock on transaction 455; blocked by process 123.
Process 123: UPDATE accounts SET balance = balance - 100 WHERE id = 2;
Process 789: UPDATE accounts SET balance = balance + 100 WHERE id = 1;
The two Process N: queries are the ones that collided — and they show the
lock order problem: one updates id 2 then id 1, the other id 1 then id 2.
MySQL/InnoDB¶
SHOW ENGINE INNODB STATUS\G -- the "LATEST DETECTED DEADLOCK" section
Shows both transactions, the locks each holds and waits for, the index
and lock mode (X, S, gap, rec but not gap), and the SQL. Enable
innodb_print_all_deadlocks = ON to log every deadlock, not just the last.
Common causes and fixes¶
Inconsistent lock ordering (the big one)¶
Two code paths lock rows/tables in different orders. Fix: always acquire locks
in the same order — e.g. always update accounts by ascending id.
-- transfer: lock the lower id first, always
BEGIN;
SELECT * FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
Lock escalation / whole-table scans under a lock¶
An UPDATE ... WHERE non_indexed_column = x locks every row it scans. Add the
index so only the matching rows are locked.
InnoDB gap locks (REPEATABLE READ)¶
INSERTs and range UPDATEs take gap locks to prevent phantoms. Two
sessions inserting into the same gap, or an insert vs a range delete, deadlock.
Options: READ COMMITTED isolation (no gap locks, if your app tolerates it),
insert in consistent key order, or use INSERT ... ON DUPLICATE KEY /
upserts carefully.
Foreign key checks¶
Inserting a child row takes a shared lock on the parent. Concurrent inserts
referencing parents + a parent update can cycle. Keep parent updates short;
consider ON DELETE/ON UPDATE implications.
Long transactions¶
The longer a transaction holds locks, the wider the window for a cycle. Keep transactions short; don't do HTTP calls or user think-time inside one.
Verification and troubleshooting¶
-- Postgres: current lock waits
SELECT blocked.pid AS blocked_pid, blocking.pid AS blocking_pid,
blocked.query AS blocked_query, blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid));
-- MySQL
SELECT * FROM performance_schema.data_lock_waits;
SELECT * FROM sys.innodb_lock_waits;
- Deadlocks under load, gone when serial — expected; the question is whether the rate is acceptable with retries. If it's high, fix lock ordering.
- Same two statements always in the log — you've found it. Reorder the locking so both paths take locks in the same sequence.
- Deadlock on
INSERTonly (InnoDB) — gap locks under REPEATABLE READ. Consider READ COMMITTED, or make the insert order deterministic, or use a dedicated sequence/table for ID allocation. - App doesn't retry — a deadlock is a transient error. Wrap the transaction in a retry loop (3–5 attempts, small jittered backoff); make it idempotent.
Lock wait timeout exceededinstead of deadlock — not a cycle, just a long lock hold. Find the blocker (sys.innodb_lock_waits/pg_blocking_pids), shorten it, or raiseinnodb_lock_wait_timeoutif the wait is legitimate.- Deadlocks after adding a trigger / FK — the trigger/constraint takes extra locks. Review what it touches and in what order.
Related tools and reading¶
- On-site: SQL / JSON formatter.
- Related posts: PostgreSQL autovacuum tuning, Connection pooling explained.
Stuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.More in Databases & Caching
PostgreSQL Autovacuum Tuning
Autovacuum reclaims dead rows and prevents transaction-ID wraparound. When it falls behind, tables bloat and queries slow — here's the formula and the fixes.
October 24, 2026MySQL Replication Basics
How MySQL replication actually works, GTID vs binlog coordinates, and the handful of commands for setup, lag monitoring, and un-breaking a stuck replica.
October 24, 2026Redis Persistence: RDB vs AOF
RDB is a compact point-in-time snapshot; AOF logs every write for better durability. Most production Redis should run both — here's why and how.
October 24, 2026