Concurrency & lockingFree lessonSource-grounded2 real lab transcripts

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.

Coordinate relations and other database objectsHeavyweight locks

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.

This relation lock coexists with ordinary INSERT, UPDATE, and DELETE relation modes. MVCC avoids row-version blocking; it does not remove relation locks.
relationaccounts
Session A · reportAccessShare · granted
  1. 1
    Session B · ALTER TABLEAccessExclusive · waiting
  2. 2
    Session 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.

Session A locks account 42. Session B locks account 99. There is no cycle yet.
Session Aholds row 42UPDATE account 42
Session Bholds row 99UPDATE account 99
after deadlock_timeoutDeadLockCheck

Hard 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
Run in psql
-- 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';
Real output PostgreSQL 17.10
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
01
LockAcquireExtendedstorage/lmgr/lock.c

Finds the heavyweight lock objects, checks granted-mode conflicts, and grants or enters the wait path.

02
ProcSleepstorage/lmgr/proc.c

Accounts for earlier conflicting waiters, queues the backend fairly, and arms deadlock timeout.

03
DeadLockCheckstorage/lmgr/deadlock.c

Traverses hard and soft wait edges, reordering soft queues or reporting a hard cycle.

04
heap_lock_tupleaccess/heap/heapam.c

Coordinates 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

  1. If ordinary reads do not take row locks under MVCC, why does PostgreSQL still have a lock manager?
  2. What table lock mode does a plain SELECT take vs a typical ACCESS EXCLUSIVE DDL, and why does that pair block?
  3. 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 in pg_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 with ACCESS EXCLUSIVE. So readers and most writers coexist.
  • ROW EXCLUSIVE (taken by INSERT/UPDATE/DELETE) lets concurrent writers proceed, row conflicts are handled separately.
  • ACCESS EXCLUSIVE conflicts with everything, including plain SELECT, which is why a careless ALTER TABLE can freeze an entire application.
Lab-verified
SQL
-- 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;
Real psql output, captured in the lab
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 ALTER fails 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 WALInsert or BufferContent).
  • Tune deadlock_timeout carefully. 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
src/backend/storage/lmgr/deadlock.cVerified · REL_17_STABLE

Primary symbol: DeadLockCheck · line 217

src/backend/storage/lmgr/lock.cVerified · REL_17_STABLE

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 →

ShareLinkedInX

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.

FollowSubstackLinkedInnew errors · lab notes · hiring loops