One session blocks, and Postgres tells you exactly who is waiting, on whom, and for what — ending in a real deadlock, detected and killed in front of you.
When two sessions want to write the same row, one of them waits. That much is obvious. What isn't obvious is that Postgres will tell you exactly who is waiting, on whom, and for what — a blocked query is not a black box. This exercise makes a session block on purpose, then walks the chain from "something is slow" to the precise process ID holding the lock, using pg_stat_activity, pg_blocking_pids() and pg_locks. Along the way: row locks turn out to have four different strengths that are not interchangeable, the thing a blocked session actually waits on is not the row, and a genuine deadlock gets detected and killed in front of you.
PostgreSQL 16 documentation.
FOR NO KEY UPDATE: "Behaves similarly to FOR UPDATE, except that the lock acquired is weaker: this lock will not block SELECT FOR KEY SHARE commands that attempt to acquire a lock on the same rows. This lock mode is also acquired by any UPDATE that does not acquire a FOR UPDATE lock." That last sentence is exactly what steps 20–22 confirm.pg_locks — the source of Part 1's aha, stated outright: "Although tuples are a lockable type of object, information about row-level locks is stored on disk, not in memory, and therefore row-level locks normally do not appear in this view. If a process is waiting for a row-level lock, it will usually appear in the view as waiting for the permanent transaction ID of the current holder of that row lock." And: "When a process finds it necessary to wait specifically for another transaction to end, it does so by attempting to acquire share lock on the other transaction's ID."pg_blocking_pids(integer): "Returns an array of the process ID(s) of the sessions that are blocking the server process with the specified process ID from acquiring a lock, or an empty array if there is no such server process or it is not blocked."pg_stat_activity (inside §28.2, The Cumulative Statistics System) — the wait_event_type / wait_event columns, and the note that "If the state is active and wait_event is non-null, it means that a query is being executed, but is being blocked somewhere in the system."SELECT reference, "The Locking Clause" — NOWAIT and SKIP LOCKED: "With NOWAIT, the statement reports an error, rather than waiting, if a selected row cannot be locked immediately. With SKIP LOCKED, any selected rows that cannot be immediately locked are skipped. Skipping locked rows provides an inconsistent view of the data, so this is not suitable for general purpose work, but can be used to avoid lock contention with multiple consumers accessing a queue-like table." That last clause is Part 3's job queue, endorsed by the manual.deadlock_timeout — Part 4. The config docs explain the delay: "The check for deadlock is relatively expensive, so the server doesn't run it every time it waits for a lock. We optimistically assume that deadlocks are not common in production applications and just wait on the lock for a while before checking for a deadlock. … The default is one second (1s)."One thing you will observe that the manual does not claim: in step 27 Postgres prints its own internal foreign-key check — SELECT 1 FROM ONLY "public"."accounts" x WHERE "id" OPERATOR(pg_catalog.=) $1 FOR KEY SHARE OF x — in an error CONTEXT. That's the server's own evidence for why FOR KEY SHARE exists, and it's better than a doc sentence.
Note also §13.3.4's explicit warning, which Part 4 tests against: "(Exactly which transaction will be aborted is difficult to predict and should not be relied upon.)"
Three ideas carry the result. The first is the one to be solid on.
xmax — carries the whole exercise. An UPDATE does not overwrite a row; it writes a new version and stamps the old one with the id of the transaction that superseded it (xmax). Steps 3–4 (a reader that doesn't block) and step 14 (waiting on a transaction id rather than a row) both fall straight out of this. → PostgreSQL docs §13.1, Introduction to MVCCDETAIL line. Every transaction holds an ExclusiveLock on its own xid for its whole life. This is the only lock object row-level waiting ever uses. → PostgreSQL docs §54.12, pg_locks6.12.76-linuxkit, 16 GiB allocated to the VMpostgres:16 — PostgreSQL 16.14 (Debian 16.14-1.pgdg13+1), digest sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20Backend process IDs, transaction IDs and tuple locations ((0,3)) below are whatever the server assigned on this run; yours will differ. Nothing in the exercise depends on their values — only on which pid appears where.
SELECT ... FOR UPDATE is the one everybody knows, and reaching for it reflexively is the most common way to cause avoidable contention. Postgres has four row-level lock modes, ordered weakest to strongest. Every one of them still lets plain SELECTs through untouched — reading never blocks writing and writing never blocks reading in Postgres, at any of these levels. These modes only conflict with each other. (That premise is worth watching hold and then break: ddia/ch08/mvcc-no-blocking has a writer sail past a long-lived reader, then adds FOR UPDATE to the read and the writer blocks — the moment a reader steps into this table.)
| Mode | What it actually does | Reach for it when… |
|---|---|---|
FOR KEY SHARE(weakest) |
Reserves the row's key only: nobody may delete it or change a column its key depends on. Anything else — including a plain UPDATE of a non-key column — proceeds. |
Almost never by hand. You use it constantly without knowing: this is the lock a foreign-key check takes on the parent row when you insert a child row. Part 2 catches Postgres doing exactly this. |
FOR SHARE |
Freezes the whole row against modification, but multiple sessions can hold it simultaneously. | "I need this row to stay exactly as it is until I commit, and so might other readers." Validating several child records against one parent config row. Note that it does not reserve a right to write later — two FOR SHARE holders who both then try to update will deadlock (Part 4's shape). |
FOR NO KEY UPDATE |
Exclusive: only one holder. Blocks FOR SHARE and stronger, but still permits FOR KEY SHARE. |
You rarely type it — this is the lock a plain UPDATE takes when it doesn't touch a key column. Worth naming because it explains why ordinary updates don't block foreign-key inserts. |
FOR UPDATE(strongest) |
Exclusive and blocks everything, including FOR KEY SHARE. |
The read-modify-write pattern: read a balance/counter/status, decide something, write it back, and you need no one else to interleave. Correct and common. Just know it is strictly stronger than the UPDATE it usually precedes — it blocks FK checks that the UPDATE itself would have let through. |
Table-level locks are a separate axis. ALTER TABLE, VACUUM FULL, CREATE INDEX (non-CONCURRENTLY) take ACCESS EXCLUSIVE and block every reader on the table. Those show up in pg_locks as locktype = 'relation', and the diagnosis path below finds them the same way.
SERIALIZABLE solves a different problem. Locks stop conflicting writes to the same row; SERIALIZABLE catches conflicts that span different rows, and it does so by aborting a transaction rather than making it wait. See transaction isolation for that side. If the rows to be contested are known upfront, FOR UPDATE is the cheaper answer — no retry loop.
Two terminals, side by side. Everything below assumes you have Docker.
docker run --name pg-locks --rm -e POSTGRES_PASSWORD=postgres -d postgres:16
Terminal A:
docker exec -it pg-locks psql -U postgres
Terminal B (a second, independent connection):
docker exec -it pg-locks psql -U postgres
In either session, create the schema once:
CREATE TABLE accounts (id int PRIMARY KEY, owner text, balance int NOT NULL);
INSERT INTO accounts VALUES (1, 'alice', 500), (2, 'bob', 500);
CREATE TABLE jobs (id int PRIMARY KEY, payload text, status text NOT NULL DEFAULT 'ready');
INSERT INTO jobs SELECT g, 'job-' || g, 'ready' FROM generate_series(1, 5) g;
CREATE TABLE transfers (id serial PRIMARY KEY, account_id int REFERENCES accounts(id), amt int);
Turn on \timing in both sessions — the wall-clock duration of a statement is the whole point of this exercise:
\timing on
From here, "A" and "B" refer to the two psql sessions. Because A is the one that will be holding the lock (and therefore sitting idle in an open transaction, free to type), all the diagnostic queries get run from A while B hangs.
What this part tests: the basic contention case from the mental model — A takes FOR UPDATE on a row, B tries to write it. First confirm the claim that readers are unaffected, then produce the block and trace it.
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
Don't commit. A is now idle inside an open transaction holding a row lock — an ordinary state for an app mid-request.
SELECT against that same locked row. Does it block?SELECT balance FROM accounts WHERE id = 1;
Instant. MVCC means B reads the last committed version without ever consulting a lock. This is worth pinning down before you go looking for contention: in Postgres, a slow SELECT is essentially never a locking problem.
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
Nothing. No prompt, no error, no timeout. B hangs indefinitely — Postgres's default is to wait forever.
A's session is still usable. Start where you would in production, with pg_stat_activity.
SELECT pid, state, wait_event_type, wait_event, left(query, 42) AS query
FROM pg_stat_activity WHERE datname = 'postgres' ORDER BY pid;
B is pid 200 here and A is 201 — but don't read anything into the ordering. Pids come from the OS in connection order and B's can land either side of A's; identify the sessions by the query column, never by which number is larger. What matters is that B is active, not idle: it is running, and wait_event_type = Lock says what it's running is a wait. Note the wait_event: not tuple, not row — transactionid. Hold that thought.
pg_stat_activity names the waiter. What names the holder?SELECT pid, pg_blocking_pids(pid) AS blocked_by, state, left(query, 38) AS query
FROM pg_stat_activity WHERE cardinality(pg_blocking_pids(pid)) > 0;
{201} is A. One query, no joins, and it answers "who is stuck behind whom" for the entire cluster. It returns an array because a waiter can be behind several holders at once — several FOR SHARE holders, or a queue. Filtering on cardinality(...) > 0 turns it into a standing "show me all contention right now" query; this is the one to keep in a snippet file.
pg_locks shows the actual lock objects. What is the thing B is waiting on — a tuple lock on accounts?SELECT pid, locktype, relation::regclass AS relation, transactionid AS xid, mode, granted
FROM pg_locks WHERE locktype IN ('relation','transactionid','tuple')
AND (relation IS NULL OR relation = 'accounts'::regclass)
ORDER BY pid, granted DESC;
Read the granted column first: exactly one row says f. That is the whole contention, and it is pid 200 wants ShareLock on transactionid 797.
Now confirm what 797 is — Session A:
SELECT pg_current_xact_id();
B is not waiting on a row. B is waiting on A's transaction ID. Every transaction holds an ExclusiveLock on its own xid for its entire life and releases it at commit or rollback (there is A's, on the last line). A row's xmax records which transaction has claimed it, so "wait for this row" is implemented as "take a ShareLock on whatever xid is in its xmax, which you'll get the moment that transaction ends." One lock object per transaction rather than per row — which is why Postgres can lock ten million rows in one statement without a lock table big enough to hold them.
Two more details in that output are worth naming:
relation locks are both granted. A holds RowShareLock, B holds RowExclusiveLock, and those two modes are compatible — the table-level locks that row operations take are deliberately weak, so the contention lands on the row and not on the table.tuple ExclusiveLock is its place in the queue for this specific row. It's what stops a third waiter from jumping ahead when A commits.COMMIT;
\timing line say?Eighty-four seconds. That figure is illustrative, not a property of the query — it is precisely how long A sat idle in its open transaction while the diagnostic queries above got typed, and yours will be whatever your own hold time was. That's the entire point: the duration of B's UPDATE is set by somebody else's transaction lifetime and by nothing about B. And it is the only visible symptom an application would ever get — no error, no warning, just a one-row UPDATE that took a minute and a half.
What this part tests: the four modes from the mental model, in the only way that matters — which pairs actually conflict. The tool for probing this is NOWAIT, which turns "block forever" into an immediate error, so each combination takes a second instead of a staring contest.
For each pair below: Session A runs BEGIN; plus the holding statement and leaves it open; Session B runs the probe (SELECT id FROM accounts WHERE id = 1 FOR <mode> NOWAIT;); then A runs ROLLBACK; before the next pair.
KEY SHARE/KEY SHARE · SHARE/SHARE · NO KEY UPDATE/KEY SHARE · NO KEY UPDATE/SHARE · UPDATE/KEY SHARE| A holds | B probes | Result |
|---|---|---|
FOR KEY SHARE | FOR KEY SHARE NOWAIT | granted |
FOR SHARE | FOR SHARE NOWAIT | granted |
FOR NO KEY UPDATE | FOR KEY SHARE NOWAIT | granted |
FOR NO KEY UPDATE | FOR SHARE NOWAIT | ERROR |
FOR UPDATE | FOR KEY SHARE NOWAIT | ERROR |
where "granted" means the row came straight back — this one is the third pair, FOR NO KEY UPDATE held against a FOR KEY SHARE probe:
and ERROR is, in full — this one the fifth pair, FOR UPDATE held against the same FOR KEY SHARE probe:
All five probes returned in under 3.4 ms; NOWAIT never waits, whether it succeeds or fails.
The two bolded rows are the interesting ones, and they're the same probe against two locks that feel equivalent. FOR NO KEY UPDATE lets a FOR KEY SHARE through; FOR UPDATE doesn't.
BEGIN; UPDATE accounts SET balance = balance - 1 WHERE id = 1;
UPDATE of balance (not a key column) — is that FOR NO KEY UPDATE or FOR UPDATE? Probe with both.SELECT id FROM accounts WHERE id = 1 FOR KEY SHARE NOWAIT;
SELECT id FROM accounts WHERE id = 1 FOR NO KEY UPDATE NOWAIT;
The FOR KEY SHARE probe is granted; the FOR NO KEY UPDATE probe is refused. A plain UPDATE takes exactly FOR NO KEY UPDATE. Which means writing SELECT ... FOR UPDATE before an UPDATE — the reflex — takes a stronger lock than the UPDATE itself would.
Something has to actually want FOR KEY SHARE for it to matter. transfers has a foreign key to accounts. A holds a lock on account 1; B inserts a transfers row pointing at account 1 — no shared row between them, B never touches accounts.
BEGIN; UPDATE accounts SET balance = balance - 1 WHERE id = 1;
UPDATE, then again under A's FOR UPDATE.UPDATE:
INSERT INTO transfers (account_id, amt) VALUES (1, 25);
No contention. Now A does ROLLBACK; then BEGIN; SELECT id FROM accounts WHERE id = 1 FOR UPDATE; and B repeats the insert (with SET lock_timeout = '3s'; so it gives up rather than hanging):
That SQL statement line is Postgres showing you its own internals: the foreign key check is a SELECT ... FOR KEY SHARE on the parent row, issued on your behalf. So SELECT ... FOR UPDATE on a parent row blocks every concurrent insert of a child row referencing it — while the UPDATE it was protecting would not have. On a busy parent row (a tenant, an account, a product) that is a real and frequently-surprising source of contention, and FOR NO KEY UPDATE fixes it with a one-word change.
Session A ROLLBACK;
What this part tests: waiting forever is a default, not a law. Postgres offers three ways out, and they are not variations on each other — they answer three different questions.
You already used it. Fail instantly rather than queue. Reach for it when a wait is meaningless: an interactive request that will time out anyway, where a fast error beats a slow success.
Wait, but bounded.
Session ABEGIN; SELECT id FROM accounts WHERE id = 1 FOR UPDATE;
SET lock_timeout = '2s';
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
Two seconds on the nose.
lock_timeout is not application code — it's your own maintenance statements. A SET lock_timeout = '2s'; before an ALTER TABLE means the migration fails fast instead of parking an ACCESS EXCLUSIVE request in front of every reader on the table.
Session A ROLLBACK;
Take a different row instead of waiting for this one. This is the job-queue pattern, and it is why "we need a real message broker" is often premature.
Session A (worker 1):BEGIN;
SELECT id, payload FROM jobs WHERE status = 'ready'
ORDER BY id LIMIT 2 FOR UPDATE SKIP LOCKED;
Jobs 3 and 4, in under two milliseconds. The number to compare it against is Part 1's UPDATE: same kind of contention, and there the wait was the holder's whole transaction lifetime. Here there is no wait at all. No coordination, no broker, no risk of two workers taking the same job. ORDER BY id LIMIT 2 was evaluated, then rows already locked were silently dropped and the scan continued.
Add UPDATE jobs SET status = 'done' WHERE id = ANY(...) before each COMMIT and you have a correct work queue in one statement. Note the failure semantics you get for free: if a worker crashes mid-job its transaction aborts, its locks vanish, and the job becomes claimable again.
Session A Session B COMMIT;
What this part tests: what happens when the waiting is circular. Nothing above can resolve it — both sides are waiting on the other's xid, and both would wait forever. This part needs precise ordering, so follow it literally.
BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1;
Session B
BEGIN; UPDATE accounts SET balance = balance - 50 WHERE id = 2;
Both succeed instantly — different rows, no conflict yet. A holds row 1, B holds row 2.
Session AUPDATE accounts SET balance = balance + 100 WHERE id = 2;
A blocks, waiting on B.
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
Postgres found the cycle and killed one side. (This exercise stops at detecting it. The fix — having both sessions acquire their rows in a consistent order, so the cycle can never close — is Step 4 of ddia/ch08/deadlock-detection, which reproduces this same interleaving from Kleppmann's side.) Read the DETAIL closely: it is the Part 1 output in miniature — two ShareLock requests on two transaction IDs, forming a loop. The deadlock detector is nothing more than a cycle search over exactly the wait-for edges you queried by hand with pg_blocking_pids().
deadlock_timeout (default 1s) and only then looks for a cycle — because building the wait-for graph is expensive and the overwhelming majority of waits resolve on their own well inside a second. The measured figure lands a few milliseconds over the timeout every time.ROLLBACK.A's blocked update completed, unblocked by the victim's rollback releasing row 2. (The 2512 ms is illustrative: it is however long A had already been waiting when the cycle closed, plus the one-second detection.) That's the design: the deadlock is broken by sacrificing exactly one transaction, and the survivor never learns anything happened beyond a slow query.
Session A COMMIT;
The server log has more than the client got:
docker logs pg-locks 2>&1 | grep -A8 'deadlock detected'
(Log timestamps are the container's UTC clock, not your local time.) The log adds the two SQL statements the client's HINT promised — two extra Process NNN: lines inside DETAIL that the client never sees. In production this is the artifact you want: it names both statements, and the fix for almost every real deadlock is visible from those two lines alone — make every transaction take its locks in a consistent order (here: always ascending id). Both transactions would then have queued behind one another instead of crossing.
A plain SELECT that never blocks, and an UPDATE on the same row that blocks for exactly as long as the other session's transaction lives — 84195 ms on this run, reported as nothing but a slow query. pg_blocking_pids() naming the holder in one line. A pg_locks row with granted = f showing that the wait is on a transaction ID, not on the row. FOR NO KEY UPDATE letting a FOR KEY SHARE probe through where FOR UPDATE rejects it — and that difference turning into a real three-second stall of a foreign-key insert, with Postgres printing its own internal ... FOR KEY SHARE OF x check in the error context. Two workers claiming disjoint job sets in under two milliseconds via SKIP LOCKED, against a wait of a minute and a half for the same contention without it. And a deadlock detected 1002 ms after the cycle closed, killing the session that closed it while the other transaction quietly succeeds.
Because Postgres implements row locking with almost no dedicated lock state. Instead of a lock manager entry per locked row, the claim is written into the row itself — the locking transaction stamps its ID into the tuple's xmax — and the only shared-memory lock object involved is the one every transaction already holds on its own transaction ID. Waiting for a row therefore reduces to waiting for a transaction, which is why pg_locks showed ShareLock on transactionid 797 and why locking ten million rows costs no more lock-manager memory than locking one. It also explains the release semantics you observed: there is no UNLOCK, and locks cannot be released early, because "released" means "that transaction ended."
The four lock strengths exist because Postgres needs the weak ones internally. Foreign-key validation has to pin a parent row's key while it checks it, but blocking every unrelated update of that parent would be disastrous on a hot row — so FOR KEY SHARE exists to conflict only with things that change keys, and FOR NO KEY UPDATE exists so ordinary updates can declare that they don't. The user-facing modes are the same mechanism exposed. That's the whole reason the reflexive FOR UPDATE overshoots: you're asking for protection against key changes you weren't worried about.
And deadlock detection is deliberately lazy because the cheap thing to do is nothing. Detecting a cycle requires assembling a global wait-for graph under a lock; almost every wait ends by itself in milliseconds. So Postgres waits deadlock_timeout first, and only pays for the graph in the rare case where the wait looks pathological. The cost of that choice is that a genuine deadlock always burns at least a second before anyone finds out — which is why the fix is ordering your locks, not tuning the detector.
UPDATE was as cheap a statement as exists — it is a property of how long somebody else's transaction stays open. That is why the 84-second figure is meaningless as a benchmark and total as a diagnosis.
pg_blocking_pids(). The third waiter reports being blocked by both of the others, and pg_locks shows the tuple lock doing its job: waiters are served first-come-first-served rather than in a stampede.FOR SHARE locks: both take FOR SHARE on the same row (compatible, both succeed), then both try to UPDATE it. Neither ever touched a second row, yet it deadlocks — a shared lock is not a reservation to upgrade, and this is the standard argument for FOR UPDATE over FOR SHARE in read-modify-write code.ALTER TABLE accounts ADD COLUMN x int; in B while A holds an open transaction that merely SELECTed from accounts. B blocks on an ACCESS EXCLUSIVE relation lock — and every subsequent plain SELECT from any session now queues behind B, since they can't jump the lock queue. This is the classic "one migration froze the whole site" incident, and lock_timeout from Part 3 is the standard guard.deadlock_timeout = '10s' and repeat Part 4. Detection takes ten seconds, which is a good way to feel why the default isn't higher — and turn on log_lock_waits to have Postgres log every wait that exceeds the timeout, which is the production setting that turns invisible contention into a searchable log line.UPDATE jobs SET status = 'done' inside the transaction, run several worker loops, and kill one mid-job to watch its rows become claimable again. Compare with the naive SELECT ... LIMIT 1 + UPDATE version, which hands the same job to every worker.PREPARE TRANSACTION that holds its row lock while owned by nobody, so pg_stat_activity has no row to find and pg_blocking_pids() comes back empty. Predict where the holder shows up instead (pg_prepared_xacts), and whether Part 3's three escape hatches help the blocked writer at all. Then predict what a docker restart does to it — the answer is nothing, which is the whole point.Sources: PostgreSQL 16 Docs §13.3: Explicit Locking (the row-level lock compatibility matrix there is exactly what Part 2 measures) · §54.12: pg_locks for the transaction-ID wait · §28.2: The Cumulative Statistics System for pg_stat_activity's wait-event columns
docker stop pg-locks
The container was started with --rm, so stopping it removes the container and the anonymous volume postgres:16 creates for /var/lib/postgresql/data. Confirm nothing is left behind:
docker ps -a --filter name=pg-locks