Lock Manager: How PostgreSQL Coordinates Concurrent Access
MVCC lets a plain read avoid locking individual rows; it does not remove every lock.
SELECT still protects referenced relations, writers serialize on tuple state, and PostgreSQL protects shared memory with LWLocks. First name the layer, then inspect the wait.
Name the waiting layer
Three lock systems. Three jobs.
PostgreSQL uses relation/object locks, tuple row-lock state, and LWLocks for different boundaries. Start by identifying the layer before diagnosing the wait.
01 / Choose the mechanism
Not every lock lives in pg_locks.
Select the resource being protected. The attached explanation shows where state lives, how long it lasts, and where you can observe it.
Conflict modes decide who can proceed.
A plain SELECT normally takes AccessShareLock on referenced relations. DDL needing AccessExclusiveLock conflicts with it and waits in the shared lock table.
- Observation
- pg_locks: yes
- Lifetime
- usually transaction; advisory can be session
Remember: a plain SELECT takes relation locks, not row locks. pg_locks shows heavyweight/predicate state, never LWLocks, and does not enumerate every held row.
02 / Build a wait queue
One waiting DDL can become the gate.
Watch a long report, an ALTER, and a later reader arrive. The queue is fairness-aware, not a simple picture of compatible current holders.
Beat 1
A report holds AccessShareLock.
- 1Session B · ALTER TABLEAccessExclusive · waiting
- 2Session C · SELECTAccessShare · queued behind conflict
03 / Close a cycle
A deadlock is a graph, not a long wait.
Two sessions can each hold what the other needs. Build the wait-for cycle, then let the detector break it.
Beat 1
Each session owns a different row.
UPDATE account 42UPDATE account 99Hard cycle found. Do not rely on which participant is aborted.
04 / Prove it yourself
Ask PostgreSQL for the blocking edge.
The canonical pg_blocking_pids lab is rendered once here. An empty captured result is a healthy instant, not a failed query.
Lab-verified · correctedOpen the psql proof
-- Who is blocking whom, right now
SELECT waiting.pid AS waiting_pid,
blocking.pid AS blocking_pid,
waiting.query AS waiting_query
FROM pg_stat_activity waiting
JOIN LATERAL unnest(pg_blocking_pids(waiting.pid)) AS bp(pid) ON true
JOIN pg_stat_activity blocking ON blocking.pid = bp.pid
WHERE waiting.wait_event_type = 'Lock';waiting_pid | blocking_pid | waiting_query
-------------+--------------+---------------
(0 rows)Payoff: during a real wait, each returned row names the waiting PID, blocking PID, and waiting query. pg_locks adds modes; wait_event tells you whether this is a heavyweight lock or another subsystem.
Source trailUnder the hood
storage/lmgr/lock.cFinds the heavyweight lock objects, checks granted-mode conflicts, and grants or enters the wait path.
storage/lmgr/proc.cAccounts for earlier conflicting waiters, queues the backend fairly, and arms deadlock timeout.
storage/lmgr/deadlock.cTraverses hard and soft wait edges, reordering soft queues or reporting a hard cycle.
access/heap/heapam.cCoordinates tuple xmax/MultiXact state with transient tuple and transaction waits.
Source-level guardrails
- Row locking is a two-level protocol. Durable state is in tuple xmax; transient heavyweight tuple/XID locks arbitrate contenders. README.tuplock
- pg_locks has a boundary. It does not enumerate persistent row holders and never exposes LWLocks. pg_lock_status
- The detector does not score a cheapest victim. The backend whose check resolves a hard cycle errors its own transaction; applications must tolerate either side. DeadLockCheck
- LWLocks have no deadlock detector. Their callers rely on strict acquisition ordering for short shared-memory critical sections. LWLockAcquire
Diagnose the resource before the query: relation/object mode conflicts, tuple transaction waits, and LWLock wait events require different evidence and different fixes.
What to remember
PostgreSQL coordinates concurrency with three tiers, heavyweight locks (in lock.c, governed by the mode conflict table), LWLocks for in-memory structures, and spinlocks beneath them. Row locks are stored in the tuple's t_xmax, deadlocks are detected via a wait-for graph after deadlock_timeout, and the one rule that saves you in production is that ACCESS EXCLUSIVE conflicts with everything. Always run DDL with a lock_timeout, and use pg_blocking_pids() to find the head of any lock queue.
Say it out loud
Close the page and explain this to someone who has not read it.
Can you separate heavyweight lock-manager locks, tuple-level row locks, and LWLocks, and explain what MVCC removes vs what still waits, without one vague bucket called "Postgres locking"?
Then compare with a full answer
Split the mechanisms or you will debug the wrong layer. MVCC means ordinary non-locking reads do not take row locks and do not block ordinary writers on version visibility, it does not mean "nothing ever waits." The heavyweight lock manager hands out relation (and other) lock modes under a conflict matrix: a normal SELECT takes ACCESS SHARE; many ALTER TABLE forms need ACCESS EXCLUSIVE; those two conflict, which is why a long report session blocks a migration (or the migration queues behind it). Row-level locks from FOR UPDATE / FOR SHARE are carried on the tuple (xmax and lock information) so concurrent writers serialize on that row without inventing a permanent lock-manager entry for every row in the table. LWLocks (and spinlocks) protect shared-memory data structures for short critical sections, buffer headers, lock tables themselves, and show up as different wait events than SQL lock modes. Deadlock detection walks the waits-for graph and aborts a victim when cycles appear. When something sticks: pg_stat_activity wait_event and blocking PIDs, pg_locks for mode and granted/waiting, then fix the holder (shorten transaction, SKIP LOCKED, schedule DDL, set lock_timeout), do not treat every stall as "add more CPUs."
It has to connect
- MVCC: plain SELECT does not row-lock writers out of existence; writers still coordinate; DDL and explicit row locks still use the lock manager / tuple locks.
- Heavyweight locks: relation (and other) lock modes with a conflict matrix, e.g. ACCESS SHARE (typical SELECT) conflicts with ACCESS EXCLUSIVE (many ALTER TABLE forms).
- Row locks: FOR UPDATE / FOR SHARE (and related) mark the tuple (xmax / lock bits) so writers queue without a full in-memory lock object per row forever.
- LWLocks: short-term protection of shared-memory structures, not the same as SQL-visible table locks; different wait events, different debugging story.
- Deadlocks: lock manager can detect cycles (DeadLockCheck family); victims error out, design to avoid circular wait, don't only "retry forever."
- Ops: pg_locks + pg_stat_activity wait_event / blocking PIDs; lock_timeout on scary DDL; keep FOR UPDATE transactions short.
Where it usually stops short
"Postgres uses locks to prevent conflicts." One undifferentiated soup. No modes, no MVCC boundary, no row vs relation vs LWLock, no diagnostic view. Cannot answer why SELECT blocks ALTER or why FOR UPDATE queues writers.
Push further
- If ordinary reads do not take row locks under MVCC, why does PostgreSQL still have a lock manager?
- What table lock mode does a plain SELECT take vs a typical ACCESS EXCLUSIVE DDL, and why does that pair block?
- Heavyweight lock vs LWLock vs FOR UPDATE on a row, what is each for, and where do you look when something waits?
Read the written reference7 sections · ~520 words · 1 runnable queryOne thing firstHeavyweight lock modes and the conflict tableRow-level locks live in the tuple, not the lock tableMultixacts: many lockers, one rowDeadlock detectionLWLocks: the in-memory workhorsesNaming the layer before you tune it
One thing first
MVCC removes the need to lock for ordinary reads, but PostgreSQL still needs locks to coordinate DDL, conflicting writes, and access to shared memory structures. There are three distinct mechanisms, each for a different purpose:
- Heavyweight locks (lock manager,
lock.c), table and row-level locks visible inpg_locks. - Lightweight locks (LWLocks), short-duration locks protecting in-memory structures like buffer headers and the WAL.
- Spinlocks, the lowest level, a few instructions guarding an LWLock's own state.
Heavyweight lock modes and the conflict table
Table-level locks come in eight modes, from ACCESS SHARE (taken by SELECT) to ACCESS EXCLUSIVE (taken by DROP, most ALTER TABLE, VACUUM FULL). The rules for which modes can coexist live in a static conflict table in src/backend/storage/lmgr/lock.c. The key facts:
ACCESS SHARE(read) conflicts only withACCESS EXCLUSIVE. So readers and most writers coexist.ROW EXCLUSIVE(taken byINSERT/UPDATE/DELETE) lets concurrent writers proceed, row conflicts are handled separately.ACCESS EXCLUSIVEconflicts with everything, including plainSELECT, which is why a carelessALTER TABLEcan freeze an entire application.
-- See current heavyweight locks and what they block
SELECT l.locktype, l.mode, l.granted,
c.relname, a.state, a.query
FROM pg_locks l
LEFT JOIN pg_class c ON c.oid = l.relation
LEFT JOIN pg_stat_activity a ON a.pid = l.pid
ORDER BY l.granted, c.relname;locktype | mode | granted | relname | state | query
------------+-----------------+---------+-----------------------------------+--------+-------------------------------------------------
relation | AccessShareLock | t | pg_authid | active | SELECT l.locktype, l.mode, l.granted, +
| | | | | c.relname, a.state, a.query +
| | | | | FROM pg_locks l +
| | | | | LEFT JOIN pg_class c ON c.oid = l.relation +
| | | | | LEFT JOIN pg_stat_activity a ON a.pid = l.pid+
| | | | | ORDER BY l.granted, c.relname;
relation | AccessShareLock | t | pg_authid_oid_index | active | SELECT l.locktype, l.mode, l.granted, +
| | | | | c.relname, a.state, a.query +
| | | | | FROM pg_locks l +
| | | | | LEFT JOIN pg_class c ON c.oid = l.relation +
| | | | | LEFT JOIN pg_stat_activity a ON a.pid = l.pid+
| | | | | ORDER BY l.granted, c.relname;
relation | AccessShareLock | t | pg_authid_rolname_index | active | SELECT l.locktype, l.mode, l.granted,Row-level locks live in the tuple, not the lock table
PostgreSQL could not store a separate lock object for every locked row, millions of locks would exhaust memory. Instead, a row lock is recorded in the tuple itself: the locking transaction writes its XID into the tuple's t_xmax with infomask flags indicating a lock rather than a delete. To wait on a locked row, a transaction waits on the holder's XID using the heavyweight lock manager. This is why SELECT ... FOR UPDATE on many rows is cheap on memory but still serializes writers per row.
Multixacts: many lockers, one row
When several transactions participate in one tuple's lock/update state, xmax can hold a MultiXactId. It maps to member XIDs and per-member statuses in the pg_multixact/offsets and pg_multixact/members SLRUs; members can include lockers and an updater, not only shared lockers.
Deadlock detection
PostgreSQL detects rather than prevents deadlocks. After a backend waits beyond deadlock_timeout, DeadLockCheck() traverses hard edges from conflicting holders and soft edges from earlier queue members. It may reorder a soft conflict; for a hard cycle, the backend whose check resolves it raises an error in its own transaction. There is no cheapest-transaction score, so applications must not rely on which session is aborted.
LWLocks: the in-memory workhorses
Access to shared structures, buffer headers, the ProcArray, WAL insertion slots, is protected by LWLocks (lwlock.c), which support shared and exclusive modes but have no deadlock detection (the code is written to always acquire them in a safe order). When you see wait events like LWLock:WALInsert or LWLock:BufferContent in pg_stat_activity, you are looking at contention on these internal locks, not user-level table locks.
Naming the layer before you tune it
- Run schema changes with a lock timeout so a blocked
ALTERfails fast instead of queuing every query behind it:SET lock_timeout = '2s'; - Acquire locks in a consistent order across your application to avoid deadlocks by construction.
- Diagnose blocking with
pg_blocking_pids()to see exactly which PID holds the lock a query is waiting on. - Watch wait events to tell the difference between heavyweight contention (a user-level lock) and LWLock contention (an internal hotspot such as
WALInsertorBufferContent). - Tune
deadlock_timeoutcarefully. It is the delay before the cycle check runs, not a correctness setting; lowering it makes detection faster but adds overhead.
Check it against the source2 citations in postgres/postgres · file, symbol and line verified on REL_17_STABLE
Primary symbol: DeadLockCheck · line 217
Primary symbol: LockAcquire · line 756
Anchored to postgres/postgres on REL_17_STABLE and cross-checked against the manual for PostgreSQL 15–18. Every query here was run and its output captured on a throwaway PostgreSQL 17.10 lab; corrections are noted inline. §13.3 Explicit Locking in the official docs →
Connected
Where this lesson sits
What comes before, after, and alongside it.
Part of these pathways
Same learning track
Finished a free lesson?
Pro opens the rest of the engine course
You felt one mechanism. Pro is the full bodies, interview depth, and the tracks that build on this session.