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.
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 — readers never block writers and writers never block readers in Postgres, at any of these levels. These modes only conflict with each other.
| 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 -p 5432:5432 -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? Click to check.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 (pid 262 here — pids are assigned by the OS, so B's can be lower than A's) 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? Click to check.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;
{263} 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? Click to check.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 262 wants ShareLock on transactionid 769.
Now confirm what 769 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? Click to check.Six seconds, which is exactly how long A sat there. That number is the only visible symptom an application would ever get: no error, no warning, just a query that took as long as somebody else's transaction.
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. Click to check.| 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:
and ERROR is, in full:
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. Click to check.SELECT id FROM accounts WHERE id = 1 FOR KEY SHARE NOWAIT;
SELECT id FROM accounts WHERE id = 1 FOR NO KEY UPDATE NOWAIT;
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. Click to check.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 last 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 0.6 ms. No coordination, no broker, no risk of two workers taking the same job — and crucially, no waiting. 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. 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.ERROR: current transaction is aborted, commands ignored until end of transaction block until you ROLLBACK.A's blocked update completed, unblocked by the victim's rollback releasing row 2. 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'
The log adds the two SQL statements the client's HINT promised. 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 — 6109 ms, 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 a millisecond via SKIP LOCKED. And a deadlock detected 1004 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 769 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.
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.Sources: PostgreSQL Docs: Explicit Locking (the row-level lock compatibility matrix there is exactly what Part 2 measures) · PostgreSQL Docs: The Statistics Collector for pg_stat_activity's wait-event columns
docker stop pg-locks