Heavyweight lock manager (lock modes, conflict table, fast-path, deadlock detector)
Simple terms
Eight table-lock strengths, and a fixed chart of which ones fight. A plain SELECT takes the weakest one, and it conflicts with exactly one thing: the strongest lock, the one DROP, TRUNCATE and most forms of ALTER TABLE take. That single pairing is behind most 'the whole app froze during a migration' stories. Deadlocks are handled separately, and not quickly. When two transactions end up waiting on each other in a circle, nothing spots it instantly: a checker wakes up about a second later, finds the cycle, and kills one of them.
You might be asked
Walk me through PostgreSQL's heavyweight lock manager. What are the table-level lock modes and how does the planner/executor decide two operations conflict? Then explain the 'fast path', what it is, which locks use it and which can't, and why it exists. Finally, how does PostgreSQL detect a deadlock, how does it choose the victim, and what does the waiter actually wait ON when two UPDATEs deadlock?
How you’d answer it
Eight table-level (heavyweight) lock modes, ordered weak to strong: AccessShare, RowShare, RowExclusive, ShareUpdateExclusive, Share, ShareRowExclusive, Exclusive, AccessExclusive. Whether two of them conflict is not computed at runtime, it is a fixed compile-time matrix, LockConflicts[] in lock.c.
Two anchor facts carry most of this topic. A plain SELECT takes AccessShareLock, which conflicts with exactly one mode: AccessExclusiveLock. And AccessExclusiveLock, taken by DROP, TRUNCATE, most ALTER TABLE and LOCK TABLE, conflicts with all 8 modes, AccessShare included. That is the entire reason a schema change blocks every reader.
Then there is the fast path. The three weak relation locks do not conflict with each other (AccessShare, RowShare, RowExclusive, i.e. modes < ShareUpdateExclusive), so a backend records them in a small per-PGPROC array of 16 slots instead of the shared hash table. Lock-free, and high-frequency read and DML locking never contends on the lock-manager LWLock partitions.
Reach for anything >= ShareUpdateExclusive and you lose that. A 'strong' lock cannot use the fast path and, worse, forces other backends to flush their matching fast-path entries into the shared table so the conflict can be seen.
Deadlock detection is not proactive, and that surprises people. A waiter sleeps in ProcSleep and arms a deadlock_timeout timer, 1s by default. Only when that fires does DeadLockCheck run a wait-for graph search (FindLockCycle). Find a cycle, pick a victim (preferring the process whose abort breaks the cycle, often itself), and that backend aborts with ERROR: deadlock detected.
One detail worth naming: in a two-UPDATE deadlock the waiters are blocked on 'ShareLock on transaction <xid>'. A row write waits on the holding transaction's xid lock, not on a table lock.
Lab (pgi17): the SELECT's AccessShareLock showed fastpath=t; a SHARE UPDATE EXCLUSIVE lock showed fastpath=f; an AccessExclusive holder blocked a SELECT with granted=f and pg_blocking_pids pointing at the holder; and two crossed UPDATEs produced 'deadlock detected' naming both transactions.
What the docs say
PostgreSQL's explicit-locking chapter lays out eight table-level lock modes and, for every SQL command, which mode it takes, a plain SELECT takes ACCESS SHARE; INSERT, UPDATE and DELETE take ROW EXCLUSIVE; VACUUM, ANALYZE and CREATE INDEX CONCURRENTLY take SHARE UPDATE EXCLUSIVE; and DROP, TRUNCATE, REINDEX and most ALTER TABLE take ACCESS EXCLUSIVE. Whether two operations conflict isn't decided case by case; it comes straight from a fixed compatibility table.
Two rows of that table carry most of the practical weight. ACCESS SHARE, the lock a read takes, conflicts with exactly one mode: ACCESS EXCLUSIVE. And ACCESS EXCLUSIVE conflicts with every mode, including ACCESS SHARE. That single fact is why a schema change that grabs ACCESS EXCLUSIVE, even for a moment, blocks every reader of the table until it finishes.
Row-level locks are a separate system. FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE and FOR KEY SHARE, along with the implicit lock an UPDATE or DELETE takes on the rows it touches, don't block plain reads at all; only conflicting writers of the same row wait on each other.
Deadlocks are handled lazily, and the manual is clear about why that's fine. PostgreSQL doesn't try to prevent them up front. A waiter simply sleeps, and only after deadlock_timeout, one second by default, does it check for a cycle of transactions waiting on each other.
If it finds one it aborts a single transaction to break the cycle and the rest proceed. Because that check fires only after a transaction has already waited a full second, the overwhelming majority of ordinary lock waits pay nothing for it. The manual's standing advice is the real fix: have your application acquire objects in a consistent order so the cycles never form.
References
- PostgreSQL 17 manual, Explicit Locking (Table-Level Lock Modes + conflict table)
- PostgreSQL 17 manual, Deadlocks
- PostgreSQL 17 manual, deadlock_timeout / log_lock_waits
- PostgreSQL 17 manual, pg_locks view + pg_blocking_pids()
- Source @REL_17_10, src/backend/storage/lmgr/lock.c (LockConflicts[] L64, EligibleForRelationFastPath L213, LockAcquireExtended L780, LockCheckConflicts L1429, FastPathTransferRelationLocks L2712)
- Source @REL_17_10, src/backend/storage/lmgr/deadlock.c (DeadLockCheck L217, FindLockCycle L443, DeadLockReport L1072)
- Source @REL_17_10, src/backend/storage/lmgr/proc.c (ProcSleep L1071, DEADLOCK_TIMEOUT arm L1274)
What the code does
Navigated at tag REL_17_10. 1) THE CONFLICT TABLE IS DATA, NOT CODE, src/backend/storage/lmgr/lock.c LockConflicts[] (lines 64-104) is a static array of bitmasks, one row per mode. Read literally: AccessShareLock's mask = LOCKBIT_ON(AccessExclusiveLock) only; AccessExclusiveLock's mask = every mode. lock_mode_names[] (L109-121) gives the 8 names. LockCheckConflicts() (L1429) is what tests a requested mode against currently-held modes using that table; DoLockModesConflict() (L572) just does `conflictTab[mode1] & LOCKBIT_ON(mode2)`. 2) ACQUIRE PATH, LockAcquire() (L756) -> LockAcquireExtended() (L780). For an eligible weak relation lock it calls FastPathGrantRelationLock() (L2645) and records the lock in the backend's PGPROC fast-path array WITHOUT inserting into the shared hash table. Eligibility is the macro EligibleForRelationFastPath() (L213): DEFAULT_LOCKMETHOD && LOCKTAG_RELATION && current database && `(mode) < ShareUpdateExclusiveLock`, so exactly AccessShare/RowShare/RowExclusive. The comment above it (L208-212) notes ShareUpdateExclusive is self-conflicting so it can't use the fast path. When a backend wants a STRONG lock (ConflictsWithRelationFastPath, mode > ShareUpdateExclusive) it bumps FastPathStrongRelationLocks (declared L258, BeginStrongLockAcquire L361) and calls FastPathTransferRelationLocks() (L2712) to pull every other backend's matching fast-path entry into the shared table so conflicts are visible. 3) GRANT vs WAIT, if no conflict, GrantLock() (L1558) marks it held; if there is a conflict, WaitOnLock() (L1818) is entered, which calls ProcSleep() (proc.c L1071). 4) DEADLOCK DETECTION IS TIMER-DRIVEN, ProcSleep() (proc.c L1071) arms the DEADLOCK_TIMEOUT timer via enable_timeout_after(DEADLOCK_TIMEOUT, DeadlockTimeout) (proc.c L1274; timeout id set L759). Only when it fires does the handler run DeadLockCheck() (deadlock.c L217), which builds/searches the wait-for graph: DeadLockCheckRecurse() (L309) -> FindLockCycle() (L443) / FindLockCycleRecurse() (L454). If a cycle is found and can't be resolved by reordering the wait queue, the victim aborts and WaitOnLock returns into DeadLockReport() (called from lock.c L1872; defined deadlock.c L1072), which builds the 'deadlock detected' ERROR with the per-process DETAIL. mechanism (source): conflicts = static LockConflicts[] table; weak relation locks bypass the shared table via per-PGPROC fast path (mode < SUE); strong locks force a transfer; deadlock = lazy timer + graph search + victim abort. consequence (measured): fastpath t vs f exactly at the SUE boundary, a granted=f wait, and a 'deadlock detected' naming both xids.
Proof from a real run
# Lab: pgi17 / PostgreSQL 17.10. Table acct(id pk, bal) 2 rows. Two psql sessions A,B (FIFO harness).
=== STAGE 1, fast-path (weak) vs shared lock table (strong) ===
# A: BEGIN; SELECT count(*) FROM acct; -> AccessShareLock
who | locktype | mode | granted | fastpath
-------+----------+-----------------+---------+----------
sessA | relation | AccessShareLock | t | t <- weak lock, recorded in per-backend array
# A: LOCK TABLE acct IN SHARE UPDATE EXCLUSIVE MODE; -> SUE (NOT fast-path eligible)
who | locktype | mode | granted | fastpath
-------+----------+--------------------------+---------+----------
sessA | relation | AccessShareLock | t | t <- still on fast path
sessA | relation | ShareUpdateExclusiveLock | t | f <- mode >= SUE -> shared lock table
# Exactly the source boundary: EligibleForRelationFastPath requires mode < ShareUpdateExclusiveLock.
=== STAGE 2, a real blocking wait (AccessExclusive vs AccessShare) ===
# A: BEGIN; LOCK TABLE acct IN ACCESS EXCLUSIVE MODE; (held)
# B: SELECT count(*) FROM acct; -> BLOCKS (AccessShare conflicts with AccessExclusive, per the table)
who | locktype | mode | granted | fastpath
-------+---------------+---------------------+---------+----------
sessA | relation | AccessExclusiveLock | t | f
sessA | transactionid | ExclusiveLock | t | f <- A's own xid lock (always held)
sessB | relation | AccessShareLock | f | f <- WAITING (granted=f)
# straight from the server:
waiter | wait_event_type | wait_event | blocked_by_pids | blocker
--------+-----------------+------------+-----------------+---------
sessB | Lock | relation | {121} | sessA <- pg_blocking_pids names the holder
# A: ROLLBACK; -> B unblocks and returns (it only ever WAITED, never errored):
2
B got result
=== STAGE 3, deadlock detector ===
# A: BEGIN; UPDATE acct ... WHERE id=1; (A holds row 1)
# B: BEGIN; UPDATE acct ... WHERE id=2; (B holds row 2)
# A: UPDATE ... WHERE id=2; -> blocks on B } cycle
# B: UPDATE ... WHERE id=1; -> blocks on A } closes it
# deadlock_timeout = 1s; after it fires, DeadLockCheck runs and aborts ONE side:
### session A log (the VICTIM) ###
UPDATE 1
ERROR: deadlock detected
DETAIL: Process 163 waits for ShareLock on transaction 381758; blocked by process 170.
Process 170 waits for ShareLock on transaction 381757; blocked by process 163.
HINT: See server log for query details.
### session B log (the SURVIVOR) ###
UPDATE 1
UPDATE 1 <- B's second UPDATE succeeded once A was aborted and released row 1
## Reading the result
- The fastpath column flips from t to f EXACTLY at the ShareUpdateExclusive boundary the source macro
encodes (mode < SUE). Weak read/DML locks never touch the shared hash table; that's the contention
win the fast path exists for.
- STAGE 2 proves the conflict table is real and asymmetric: a plain SELECT is blocked by a schema-style
AccessExclusive lock, and pg_blocking_pids points straight at the holder. This is the mechanism behind
'my SELECTs hang during a migration'.
- STAGE 3's DETAIL is the honest nuance: two row UPDATEs deadlock by each waiting for 'ShareLock on
transaction <xid>', a writer that hits a locked row waits on the OTHER transaction's xid lock, not a
table lock. The detector named both xids and aborted the one whose abort breaks the cycle.
- Victim selection here picked A (the process that detected the cycle on its wait); the survivor B
completed both statements. Which side is chosen is not guaranteed run-to-run.Using it under pressure
A situation where it matters
A team runs a routine migration: ALTER TABLE orders ADD COLUMN ... during business hours. ALTER TABLE takes ACCESS EXCLUSIVE, which (per the conflict table) conflicts with ACCESS SHARE, i.e. with every SELECT. The ALTER itself is instant, but it can't GET the lock because long-running SELECTs hold AccessShare; meanwhile it sits at the head of the lock queue, and because a strong lock is queued, NEW SELECTs now queue behind IT. Within seconds the app looks completely down: every query 'hangs'. pg_locks shows the ALTER's AccessExclusiveLock granted=f and a pile of AccessShareLock waiters behind it; pg_blocking_pids traces the chain.
Separately, a nightly job that updates account rows in id order while an ad-hoc script updates them in reverse order produces sporadic 'ERROR: deadlock detected', each holds one row and waits on 'ShareLock on transaction' of the other. The lock manager is behaving exactly as designed in both cases; the fixes are operational (lock_timeout + short transactions + retry on the ALTER; consistent row-ordering or SELECT ... FOR UPDATE ordering for the updaters), not a server bug.
The call you would make
Design with the conflict table in mind.
- 1
Treat ACCESS EXCLUSIVE operations (most ALTER TABLE, DROP, TRUNCATE, REINDEX, VACUUM FULL, CLUSTER) as 'blocks all readers' and run them with a short
lock_timeout+ retry, off-peak, and split into the least-locking form (e.g. many ALTER subcommands now take weaker locks; ADD COLUMN with a non-volatile default is metadata-only but still needs the brief AccessExclusive). - 2
Keep transactions short: a held lock is only released at COMMIT/ROLLBACK, and a strong-lock waiter at the queue head stalls everyone behind it.
- 3
Lock objects (and rows) in a CONSISTENT order across all code paths to prevent deadlocks; for multi-row writers, order by the same key or take SELECT ... FOR UPDATE in a defined order first.
- 4
Don't fear the fast path: high read/DML concurrency on a table is cheap because AccessShare/RowShare/RowExclusive don't contend on the shared lock table, but know that introducing even one strong-lock acquirer forces fast-path transfers and serializes through the lock-manager partitions.
- 5
Expect and handle deadlocks: they are normal under concurrency; wrap writers in a retry-on-40P01 loop rather than trying to eliminate every possible cycle.
- 6
Tune
deadlock_timeoutup only if you have provably few deadlocks and want to spend less on detection; lowering it makes detection snappier but adds graph-check overhead to ordinary waits.
When it goes wrong
'Everything is hanging' / lock pile-ups:
- 1
SELECT pid,
wait_event_type,wait_event, state, query FROMpg_stat_activityWHEREwait_event_type='Lock'; then SELECT pid,pg_blocking_pids(pid) FROMpg_stat_activityWHERE cardinality(pg_blocking_pids(pid))>0; the root blocker is the pid that appears in others'blocked_bybut is itself not blocked. - 2
Inspect
pg_locksfor granted=f rows and join topg_stat_activityto see the mode being waited for; a single AccessExclusive waiter at the head of the queue explains a fleet of stalled SELECTs. - 3
Kill the head blocker with
pg_cancel_backend(pid) (orpg_terminate_backendas a last resort) and addlock_timeoutto the offending DDL so it self-aborts instead of stampeding. For DEADLOCKS: - 4
The app sees 'ERROR: deadlock detected' (SQLSTATE 40P01); the server log (
log_lock_waits=on) records the full cycle and the queries, that DETAIL tells you the exact two statements/rows; fix the lock ordering or add a retry. - 5
Frequent deadlocks on row updates usually mean inconsistent update order or missing FOR UPDATE ordering.
- 6
If fast-path-related contention is suspected (many strong locks), watch for LWLock:LockManager waits in
pg_stat_activity. - 7
Note: a waiter on 'ShareLock on transaction <xid>' is a ROW-level conflict (waiting for the holder to end), distinct from a relation-lock wait.
What people get wrong
Myth 1: 'Readers never block.' A plain SELECT takes AccessShareLock and IS blocked by AccessExclusive (DDL), proven live (granted=f). Myth 2: 'Lock conflicts are computed dynamically.' They're a fixed compile-time matrix (LockConflicts[]); nothing is negotiated. Myth 3: 'The fast path makes all locks cheap.' Only the three weak relation locks (mode < ShareUpdateExclusive) use it; the moment any backend takes a strong lock, others' fast-path entries are transferred to the shared table and you serialize on lock-manager partitions.
Myth 4: 'PostgreSQL prevents deadlocks.' It DETECTS them after the fact (after deadlock_timeout) and aborts a victim; you still get the error and must retry. Myth 5: 'Detection is continuous/expensive.' It's lazy, armed by a timer only when you actually wait, so normal waits cost nothing. Myth 6: 'Two UPDATEs deadlock on the table lock.' No, they deadlock waiting for each other's TRANSACTION id lock (ShareLock on transaction), a row-level conflict; the DETAIL names the xids.
Myth 7: 'ADD COLUMN with a default rewrites the table and holds the lock for ages.' Since PG11 a non-volatile default is metadata-only, but it still briefly needs ACCESS EXCLUSIVE, which is enough to stampede readers if it queues behind a long SELECT. Myth 8: 'ShareUpdateExclusive is a weak lock.' It is self-conflicting and excluded from the fast path; two ShareUpdateExclusive operations (e.g. two ANALYZE/VACUUM) block each other.
Follow-ups they'll push on
Q. Why does AccessExclusive conflict with AccessShare but AccessShare doesn't conflict with much?
AccessExclusive means 'I'm changing the object's definition/contents wholesale' (DDL, TRUNCATE), so it must exclude even readers. AccessShare means 'I'm just reading', and only a wholesale change (AccessExclusive) can't tolerate a concurrent reader. The asymmetry IS the design: reads are cheap, schema changes are exclusive.
Q. What exactly is on the fast path and why 16 slots?
Per-backend, a small fixed array (16 relation-lock slots, grouped) holding the weak relation locks so the common read/DML path never inserts into the shared hash table or grabs a lock-manager partition LWLock. 16 covers typical statements; overflow falls back to the shared table.
Q. What is FastPathTransferRelationLocks for?
When backend X takes a strong relation lock, other backends' weak fast-path locks on that relation are invisible to X (they're in private arrays). The transfer pulls them into the shared table so X's conflict check can see them. That's the hidden cost of strong locks under concurrency.
Q. How is the deadlock victim chosen?
The detector searches the wait-for graph; if a cycle is found it first tries to resolve by reordering soft (queue) edges, and if it can't, it aborts a process on the cycle, effectively the one whose abort breaks it (often the detector itself). It's not strictly 'youngest' or 'least work'.
Q. Does lowering deadlock_timeout help?
It detects deadlocks faster but adds graph-check overhead to EVERY lock wait that lasts that long (most of which aren't deadlocks). Default 1s is a deliberate trade-off; usually leave it and fix lock ordering instead.
Q. Relation lock vs row lock vs tuple lock?
Relation locks (the 8 modes) are in the lock manager/pg_locks. Row locks are stored in the tuple header (xmax + infomask) and, when contended, surface as waiting on the holder's transactionid (ShareLock on transaction) plus a transient 'tuple' lock to serialize waiters. The deadlock proof showed the transactionid waits.
Q. Can SELECT ever take something stronger than AccessShare?
SELECT ... FOR UPDATE/FOR SHARE takes RowShare at the table level (plus row locks); plain SELECT is AccessShare.
Version notes
The 8 modes, the LockConflicts[] matrix, the fast-path mechanism, and timer-driven deadlock detection are structurally identical across PG 14-18; line numbers read at REL_17_10 (lock.c LockConflicts[] L64-104, lock_mode_names L109-121, EligibleForRelationFastPath L213, LockAcquire L756 / LockAcquireExtended L780, DoLockModesConflict L572, LockCheckConflicts L1429, GrantLock L1558, WaitOnLock L1818 with DeadLockReport call L1872, FastPathStrongRelationLocks L258, FastPathGrantRelationLock L2645, FastPathTransferRelationLocks L2712; proc.c ProcSleep L1071, DEADLOCK_TIMEOUT arm L1274; deadlock.c DeadLockCheck L217, DeadLockCheckRecurse L309, FindLockCycle L443, DeadLockReport L1072).
Across versions many ALTER TABLE subcommands have been progressively weakened from AccessExclusive to ShareUpdateExclusive, and non-volatile column defaults became metadata-only in PG11, both reduce how often a strong lock stampedes readers, but the lock-manager mechanics are unchanged. deadlock_timeout default 1s throughout. Captured on 17.10; demo.sh runs on pgi14-pgi18.
How this was verified
This concept is free end to end. The manual section is grounded in the official PostgreSQL documentation, the source walk was navigated in postgres/postgres at a version-pinned tag, and the evidence is labeled as raw output or a captured run summary. Where the text distinguishes mechanism (from docs/source) from consequence (measured), that boundary is kept explicit.
Connected
Where this concept connects
How this concept links across the library, the interview questions that test it, its plain-English glossary definition, and the guided pathways it belongs to. Open the full map to explore further.
Part of these pathways
Related concepts