PostgreSQL · lesson 5 of 7
Intermediate
6- 1 EXPLAIN vs EXPLAIN ANALYZE
- 2 Indexing Strategy: When an Index Actually Helps
- 3 JSONB Containment Queries Need a GIN Index, Not a B-tree
- 4 VACUUM Doesn't Shrink Tables — VACUUM FULL Does, at a Cost
- 5 A Real Deadlock, and Why Lock Order Fixed It
- 6 The WAL Durability Tradeoff: 0.30s vs 0.02s for the Same 200 Inserts
Also part of PostgreSQL
7A Real Deadlock, and Why Lock Order Fixed It
One error inside a transaction poisons every statement after it until ROLLBACK — and two transactions locking the same two rows in opposite order produces a real 'deadlock detected' error, not a hang.
By SkillByExample · Published
Postgres transactions fail in two very different ways — one where a single mistake poisons everything after it, and one where two transactions can trap each other completely.
A transaction aborts entirely on the first error
BEGIN;
SELECT 1/0;
SELECT 'still in transaction?';
ROLLBACK;
BEGIN
ERROR: division by zero
ERROR: current transaction is aborted, commands ignored until end of transaction block
ROLLBACK
The division by zero was expected to fail — what surprises people is the second error. Once
a transaction hits an error, Postgres refuses to run anything else inside it, successful-looking
statement or not, until ROLLBACK (or COMMIT, which behaves the same way on an already-dead
transaction) explicitly ends it.
The bug: a real deadlock
Two transactions, each updating the same two rows in opposite order:
-- Session A
BEGIN;
UPDATE books SET title = title WHERE id = 1;
SELECT pg_sleep(3);
UPDATE books SET title = title WHERE id = 2;
COMMIT;
-- Session B, started while A is mid-sleep
BEGIN;
UPDATE books SET title = title WHERE id = 2;
UPDATE books SET title = title WHERE id = 1;
COMMIT;
Session A’s result:
BEGIN
UPDATE 1
pg_sleep
----------
(1 row)
ERROR: deadlock detected
DETAIL: Process 408 waits for ShareLock on transaction 778; blocked by process 410.
Process 410 waits for ShareLock on transaction 777; blocked by process 408.
HINT: See server log for query details.
CONTEXT: while updating tuple (3837,35) in relation "books"
Session B’s result:
BEGIN
UPDATE 1
UPDATE 1
COMMIT
A locked row 1, then tried to lock row 2 (held by B). B locked row 2, then tried to lock row 1 (held by A). Neither could proceed — a real circular wait. Postgres’s deadlock detector found the cycle, picked session A as the victim, and aborted it with a real error naming the exact processes and transaction IDs involved. B, freed from the cycle, completed normally.
The fix: always lock in the same order
Same two transactions, both now touching row 1 before row 2:
-- Session A (unchanged)
BEGIN;
UPDATE books SET title = title WHERE id = 1;
SELECT pg_sleep(3);
UPDATE books SET title = title WHERE id = 2;
COMMIT;
-- Session B, lock order now matches A's
BEGIN;
UPDATE books SET title = title WHERE id = 1;
UPDATE books SET title = title WHERE id = 2;
COMMIT;
Both sessions now complete successfully — B’s output:
BEGIN
UPDATE 1
pg_sleep
----------
(1 row)
UPDATE 1
COMMIT
B simply waits for A to finish with row 1 rather than racing it for row 2 in the opposite direction. No cycle is possible when every transaction touching the same rows agrees on the order to touch them in — the wait is real, but it resolves on its own instead of requiring Postgres to kill a transaction.
Practice
1. Inspect pg_locks while a transaction holds a row lock.
BEGIN;
UPDATE books SET title = title WHERE id = 1;
SELECT locktype, relation::regclass, mode, granted FROM pg_locks WHERE relation = 'books'::regclass;
BEGIN
UPDATE 1
locktype | relation | mode | granted
----------+----------+------------------+---------
relation | books | RowExclusiveLock | t
(1 row)
RowExclusiveLock is the table-level lock an UPDATE takes automatically — it allows other
transactions to still read or update different rows, but coordinates writes to the same
table at a coarser level than just the individual row being changed.
2. Confirm that ROLLBACK, not just ending the session, is what releases a lock.
BEGIN;
UPDATE books SET title = title WHERE id = 1;
ROLLBACK;
SELECT count(*) FROM pg_locks WHERE relation = 'books'::regclass;
BEGIN
UPDATE 1
ROLLBACK
count
-------
0
(1 row)
Zero locks remain immediately after ROLLBACK — locks are tied to the transaction’s lifetime,
not to how long the connection stays open.
3. Use SELECT ... FOR UPDATE to explicitly lock a row before reading it, without changing it.
BEGIN;
SELECT title FROM books WHERE id = 1 FOR UPDATE;
COMMIT;
BEGIN
title
----------------
Book 1 (A) (B)
(1 row)
COMMIT
FOR UPDATE takes the same row lock an UPDATE would, without actually changing anything —
the standard way to read a row with the intent to modify it later in the same transaction,
preventing another transaction from changing it out from underneath the read.
Next: the write-ahead log — what actually makes COMMIT durable.