Locks & Deadlocks
// the problem
MVCC means readers never block writers and writers never block readers — but two transactions that write the same row still have to take turns. That's a lock. Usually it's invisible; occasionally two transactions each hold what the other needs, wait forever, and Postgres has to shoot one of them. Let's make locks visible and manufacture a deadlock on purpose.
Two transactions, two rows. Lock rows in different orders and watch one block — then deadlock.
holds: —
holds: —
lock table
try: A locks 1 · B locks 2 · A locks 2 (waits) · B locks 1 → deadlock
// note · reproduce the classic deadlock
A → Lock row 1, B → Lock row 2, then A → Lock row 2 (A now waits for B), then B → Lock row 1. Now each transaction holds what the other wants: a cycle. Postgres's deadlock detector notices and aborts one so the other can proceed. Do the same sequence but with both transactions locking 1 then 2 — no deadlock, just a normal wait.
What actually takes a lock
Reads under MVCC take no row locks at all. Locks appear only when you write, or explicitly ask. There are four row-lock strengths, strongest to weakest:
FOR UPDATE— taken byUPDATE/DELETEandSELECT … FOR UPDATE. The exclusive one: nobody else may lock, update, or delete the row.FOR NO KEY UPDATE— a slightly weaker exclusive lock, taken by updates that change no key column; it conflicts with less.FOR SHARE— shared: others may alsoFOR SHARE-read it, but none may update it.FOR KEY SHARE— the weakest; it only prevents the row's key from changing. This is what a foreign key quietly takes on a parent row so it can't be deleted out from under a child.
Here's what a FOR UPDATE actually registers:
Notice there is no per-row entry — just a RowShareLock on the table and a
lock on the transaction's own id. That's deliberate: tracking every locked row in
shared memory couldn't scale to millions of locks. Instead a row lock is written
onto the tuple itself — Postgres stamps the row's xmax with the locking
transaction's id (plus an infomask bit meaning "locked, not deleted"). See it:
A would-be writer reads that xmax, sees a live transaction holds it, and waits.
No shared-memory entry per row — the lock is a few bits in the tuple header.
Every statement also locks the table
Beyond row locks, every statement takes a table-level lock — a weak one you
never notice until a strong one collides with it. SELECT takes ACCESS SHARE;
INSERT/UPDATE/DELETE take ROW EXCLUSIVE; CREATE INDEX takes SHARE; and
ALTER TABLE, DROP, TRUNCATE, and VACUUM FULL take ACCESS EXCLUSIVE —
which conflicts with everything, including a plain SELECT. Watch the mode
change with the statement:
// gotcha · this is why a careless migration takes down an app
An ALTER TABLE that needs ACCESS EXCLUSIVE must first wait for every current
reader and writer to finish — and once it's queued, it blocks all the new
SELECTs arriving behind it too. On a busy table, a change that takes one second to
apply can stall the whole application for far longer. (Same reason VACUUM FULL's
ACCESS EXCLUSIVE lock is an outage.) Run schema changes with a short
lock_timeout so they fail fast instead of building a queue behind them.
Waiting, and the deadlock detector
When you request a lock someone else holds, your transaction joins a wait queue for it — first-come-first-served, so waiters can't starve. Usually the holder commits a moment later and you proceed. A deadlock is the pathological case: a cycle of waiters — A waits for B while B waits for A — which left alone would never resolve.
Postgres breaks the cycle, but lazily: checking for one on every lock wait would
be wasteful, so a blocked backend only runs the deadlock detector after it's
been waiting for deadlock_timeout (1 second by default). The detector builds
a wait-for graph of who's blocked on whom, searches it for a cycle, and if it
finds one aborts a victim — rolling that transaction back with a deadlock detected error, which frees its locks so everyone else proceeds. Your app should
catch that error and retry the transaction.
Two related knobs bound a query's patience: lock_timeout caps any single
lock wait, and statement_timeout caps the whole statement — both let a query
give up instead of blocking forever.
// why it matters · the golden rule: lock in a consistent order
Deadlocks almost always come from transactions grabbing the same rows in different orders. If every transaction touches rows (and tables) in the same order — e.g. always the lower account id first — a cycle can never form. This one discipline prevents the vast majority of deadlocks.
Skipping and not-waiting
Sometimes you don't want to wait at all:
FOR UPDATE NOWAIT— fail immediately instead of blocking.FOR UPDATE SKIP LOCKED— ignore already-locked rows and take the rest. This is the backbone of queue/worker patterns: many workers eachSELECT … FOR UPDATE SKIP LOCKED LIMIT 1and never contend on the same job.
Advisory locks: your own mutexes
Sometimes you want a lock tied to no row at all — a mutex around an application operation (“only one worker rebuilds this cache at a time”). Advisory locks are arbitrary integer locks Postgres tracks for you but never enforces on its own; their meaning is entirely up to your code.
They come in two scopes. Session-level (pg_advisory_lock / the non-blocking
pg_try_advisory_lock) is held until you unlock it or disconnect — powerful, but
a forgotten unlock, or a connection handed back to a pool, can leak it.
Transaction-level (pg_advisory_xact_lock) releases automatically at commit
or rollback, so it can't leak — usually the safer choice. Reach for the
pg_try_* variants (which return false instead of blocking) for the classic
"only one worker at a time" pattern.
Your turn
Lock row 1 of the account table for a pending update, returning its id and balance. (Wrap it in a transaction.)
Grab one job from the queue without ever waiting on a row another worker already holds. Return its id.
// what you now understand
- 01MVCC removes read/write blocking, but two transactions writing the same row serialize — the second waits on a row lock.
- 02Four row-lock strengths: FOR UPDATE > FOR NO KEY UPDATE > FOR SHARE > FOR KEY SHARE (foreign keys take FOR KEY SHARE on the parent). Row locks aren't in pg_locks — they're stamped onto the tuple's xmax, so they scale to millions.
- 03Every statement also takes a table lock: SELECT = ACCESS SHARE, writes = ROW EXCLUSIVE, DDL / VACUUM FULL = ACCESS EXCLUSIVE — which conflicts with everything and can stall an app behind its queue.
- 04A deadlock is a cycle of waiters; a blocked backend runs the detector after deadlock_timeout (1s), builds a wait-for graph, and aborts a victim — the app should catch the error and retry. lock_timeout / statement_timeout bound how long a query waits.
- 05Deadlocks come from inconsistent lock ordering — always acquire rows/tables in the same order to prevent them.
- 06FOR UPDATE NOWAIT fails instead of waiting; SKIP LOCKED ignores locked rows — the basis of SELECT-FOR-UPDATE work queues.
- 07Advisory locks are arbitrary integer mutexes; session-scoped ones can leak, transaction-scoped (pg_advisory_xact_lock) auto-release at commit.
// self-test
Two transactions deadlock. What does Postgres do?
// self-test
Your service occasionally deadlocks when transferring between two accounts. What's the most reliable fix?
// go deeper
- Explicit Locking (official docs) — row/table lock modes, the conflict matrix, advisory locks
- Deadlocks — detection, deadlock_timeout, avoidance
- pg_locks view — inspecting held and awaited locks
- SELECT … FOR UPDATE / SKIP LOCKED — locking clauses and the queue pattern