Chapter 07 · MVCC, Transactions, Isolation, Locks, and Serialization

Table, Row, Page, Advisory, and Predicate Locks with Lock Compatibility

Understand PostgreSQL lock families, compatibility and observability: explicit table modes, row locks, internal page locks, application-defined advisory locks, nonblocking predicate locks, and correct pg_locks interpretation.

Intermediate → Advanced145–180 minutesLock compatibility + pg_locks labCurrent patched PostgreSQL 18.xCore PostgreSQL; no third-party dependencyTwo or more local psql sessions where indicatedLast reviewed: August 2026

Learning outcomes

When operators see dozens of rows in pg_locks, an easy mistake is to conclude that “the database is heavily blocked.” PostgreSQL normally holds many compatible locks during healthy work. The right question is: which resource, requested mode, holder, waiter, and compatibility rule form a wait relationship? This lesson builds that vocabulary across table, row, page, advisory, and Serializable predicate locks.

01

Distinguish table-level lock modes from row-level locks even when table-mode names contain the word ROW.

02

Use compatibility to predict whether a second request waits rather than assuming every lock conflicts.

03

Explain why ordinary row locks are recorded in tuple headers and therefore often do not appear as tuple rows in pg_locks.

04

Use transaction-level advisory locks for application-defined coordination without confusing them with database-enforced row integrity.

05

Observe SIReadLock predicate-lock metadata and explain why it detects Serializable conflicts without blocking writers.

1. Lock families solve different problems

Family Typical purpose Lifetime / observation
table-level Protect relation-wide operations and coordinate DDL/DML. Held to transaction end; visible in pg_locks as relation locks.
row-level Protect selected tuples against conflicting writers/lockers. Stored primarily in tuple headers; a waiter often waits on holder transaction ID.
page-level Short-lived internal coordination in access methods. Usually internal and released quickly; not an application locking API.
advisory Application-defined resource coordination. Session or transaction level; visible as advisory locks.
predicate / SIReadLock Detect Serializable read/write dependency structures. Nonblocking metadata; may be tuple/page/relation granularity.
Terminology trap

ROW SHARE and ROW EXCLUSIVE are table-level lock mode names. They do not mean “lock one row.” Actual row locks come from SELECT ... FOR UPDATE/NO KEY UPDATE/SHARE/KEY SHARE or from row-changing DML.

2. Set up lock targets

sql · setup
DROP TABLE IF EXISTS app.ch07_lock_demo;CREATE TABLE app.ch07_lock_demo (    item_id integer PRIMARY KEY,    payload text NOT NULL,    category text NOT NULL);INSERT INTO app.ch07_lock_demo VALUES(1,'alpha','A'),(2,'beta','A'),(3,'gamma','B');

3. Table locks: predict waits from compatibility

PostgreSQL has eight table-level modes. Rather than memorize their names in isolation, connect them to operations and conflicts. Ordinary SELECT takes ACCESS SHARE. INSERT/UPDATE/DELETE take ROW EXCLUSIVE. Many DDL operations require stronger modes; ACCESS EXCLUSIVE conflicts with every table mode.

Mode Common intuition Selected conflicts
ACCESS SHARE Ordinary readers. Conflicts with ACCESS EXCLUSIVE.
ROW SHARE SELECT FOR UPDATE/SHARE intent. Conflicts with EXCLUSIVE and ACCESS EXCLUSIVE.
ROW EXCLUSIVE INSERT/UPDATE/DELETE intent. Conflicts with SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE.
SHARE Allow readers but block ordinary writers. Conflicts with ROW EXCLUSIVE and stronger write-oriented modes.
ACCESS EXCLUSIVE Strongest relation exclusion. Conflicts with all table-level modes.

The full compatibility matrix in the PostgreSQL manual is authoritative; this compact table is for reasoning, not a substitute for checking unusual modes.

sql · Session A — hold SHARE
BEGIN;LOCK TABLE app.ch07_lock_demo IN SHARE MODE;SELECT pg_backend_pid() AS session_a_pid;-- Keep transaction open.
sql · Session B — compatible read, conflicting writer-intent lock
BEGIN;LOCK TABLE app.ch07_lock_demo IN ACCESS SHARE MODE; -- succeedsSET LOCAL lock_timeout = '750ms';LOCK TABLE app.ch07_lock_demo IN ROW EXCLUSIVE MODE; -- waits, then errors\errverboseROLLBACK;
text · expected error class
ERROR:  canceling statement due to lock timeoutSQLSTATE: 55P03  (lock_not_available)

Session B successfully held Access Share while A held Share because those modes are compatible. Its later Row Exclusive request conflicted. The existence of A's lock alone did not imply blocking.

4. Observe holder and waiter, not just rows in pg_locks

To see a true wait, repeat the conflict without a very short timeout and inspect from a third session. pg_stat_activity.wait_event_type = 'Lock' plus pg_blocking_pids(pid) are easier to interpret than guessing from lock rows alone.

sql · Session C — blocking graph
SELECT a.pid,       a.usename,       a.state,       a.wait_event_type,       a.wait_event,       pg_blocking_pids(a.pid) AS blocking_pids,       left(a.query, 90) AS queryFROM pg_stat_activity AS aWHERE a.datname = current_database()ORDER BY a.pid;SELECT pid, locktype, mode, granted, relation::regclass AS relation,       transactionid, virtualxidFROM pg_locksWHERE relation = 'app.ch07_lock_demo'::regclass   OR pid = ANY (       SELECT pid FROM pg_stat_activity WHERE datname = current_database()   )ORDER BY pid, granted, locktype, mode;

granted = false identifies an ungranted lock request, but blocker diagnosis still needs resource/mode context. Also, pg_locks spans the cluster. Relation OIDs can only be resolved reliably through the current database's catalogs for locks belonging to that database.

5. Row locks: normal SELECT still does not conflict with a writer

A row-level FOR UPDATE lock blocks conflicting writers and row lockers, but an ordinary SELECT uses MVCC and continues to read a visible version.

sql · Session A — row lock
ROLLBACK; -- release any table-lock exercise firstBEGIN;SELECT * FROM app.ch07_lock_demoWHERE item_id = 1FOR UPDATE;-- Keep open.
sql · Session B — ordinary read succeeds, NOWAIT lock fails
SELECT * FROM app.ch07_lock_demo WHERE item_id = 1; -- succeedsBEGIN;SELECT * FROM app.ch07_lock_demoWHERE item_id = 1FOR UPDATE NOWAIT;\errverboseROLLBACK;
text · expected NOWAIT failure
ERROR:  could not obtain lock on row in relation "ch07_lock_demo"SQLSTATE: 55P03  (lock_not_available)

Do not be surprised if the holder's row lock is not displayed as a simple locktype='tuple' row in pg_locks. PostgreSQL records row read/write locks directly in tuple headers. A waiter commonly appears as waiting on the holder's transaction ID. The supplied pgrowlocks extension can inspect row-lock information, but it is optional and not required for this chapter.

Row-lock strength

FOR UPDATE is strongest. FOR NO KEY UPDATE is weaker and can coexist with FOR KEY SHARE. Choose the weakest mode that preserves correctness rather than reflexively using FOR UPDATE everywhere.

6. Page locks are mostly internal

PostgreSQL does use page-level locks internally, including short-lived locks in index access methods. These are not a general SQL API for “locking page 42.” Application designs should not coordinate work using physical page identities; pages change as storage evolves. Observe them only as diagnostic internals when relevant.

7. Advisory locks coordinate application-defined resources

Advisory locks attach locking semantics to integer keys whose meaning is defined by your application—for example, “tenant 42 monthly close.” PostgreSQL does not know which table rows that key represents, so advisory locks complement rather than replace constraints and row locks.

sql · transaction-level advisory lock
-- Session ABEGIN;SELECT pg_advisory_xact_lock(7001);SELECT pg_backend_pid();-- Keep open.-- Session BBEGIN;SELECT pg_try_advisory_xact_lock(7001) AS acquired;-- Expect false while A holds it.ROLLBACK;-- Session ACOMMIT; -- transaction-level advisory lock is released automatically
sql · observe advisory lock
SELECT pid, locktype, mode, granted, classid, objid, objsubidFROM pg_locksWHERE locktype = 'advisory'ORDER BY pid, granted;

Session-level advisory locks differ: repeated acquisitions stack and require matching unlock calls (or session termination). For short business operations, transaction-level advisory locks are easier to make failure-safe because PostgreSQL releases them at transaction end.

8. Predicate locks: Serializable conflict evidence without blocking

sql · Session A — observe SIReadLock
BEGIN ISOLATION LEVEL SERIALIZABLE;SELECT count(*)FROM app.ch07_lock_demoWHERE category = 'A';SELECT locktype, mode, relation::regclass, page, tuple, grantedFROM pg_locksWHERE pid = pg_backend_pid()  AND mode = 'SIReadLock'ORDER BY locktype, relation::regclass::text, page, tuple;-- Keep transaction open briefly for Session B.
sql · Session B — writer is not blocked by SIReadLock
UPDATE app.ch07_lock_demoSET payload = payload || '-changed'WHERE item_id = 1;-- This can commit; SIReadLock is not a blocking lock.

The exact SIReadLock granularity can differ with plan choice and predicate-lock promotion, so do not write monitoring logic that requires “one tuple lock per row read.” Serializable uses this metadata to recognize dependency structures; it does not turn predicate reads into blocking mutexes.

9. Row-lock compatibility: choose the weakest lock that protects the relationship

PostgreSQL has four row-level lock strengths. Their names matter because foreign-key checks and key-changing UPDATEs interact differently with them. A useful mental model is that FOR UPDATE is the strongest; FOR NO KEY UPDATE allows more concurrency when the row's key used by foreign keys will not change; FOR SHARE is a shared lock against stronger modifications; and FOR KEY SHARE protects a referenced key while still allowing non-key updates.

Requested mode Conflicts with another transaction holding Typical purpose
FOR UPDATE UPDATE, NO KEY UPDATE, SHARE, KEY SHARE Strong row ownership before an update/delete or sensitive transition.
FOR NO KEY UPDATE UPDATE, NO KEY UPDATE, SHARE Protect row from competing updates while allowing KEY SHARE.
FOR SHARE UPDATE, NO KEY UPDATE Shared row protection against modifications requiring those stronger modes.
FOR KEY SHARE UPDATE Protect key identity/reference while permitting non-key updates.

The exact conflict table in the PostgreSQL manual remains the source of truth. The production design point is to avoid escalating to FOR UPDATE merely because it is familiar. Stronger locks can turn safe concurrency into unnecessary waits.

Lock waits often surface as transaction-ID waits

Because row-lock state is stored in tuple headers, a backend that wants a conflicting row lock may discover which transaction currently owns the relevant state and then wait for that transaction ID to finish. This is why blocker diagnosis should join activity and lock-manager evidence rather than searching only for locktype='tuple'.

sql · focus pg_locks on ungranted requests and blockers
SELECT a.pid,       a.wait_event_type,       a.wait_event,       pg_blocking_pids(a.pid) AS blockers,       l.locktype,       l.mode,       l.granted,       l.relation::regclass AS relation,       l.transactionidFROM pg_stat_activity AS aLEFT JOIN pg_locks AS l ON l.pid = a.pidWHERE a.datname = current_database()  AND (a.wait_event_type = 'Lock' OR l.granted = false)ORDER BY a.pid, l.granted, l.locktype;

This query is a diagnostic starting point, not a complete blocking-tree algorithm. A blocker may itself be blocked, prepared transactions can hold locks without a normal backend PID, and rapidly changing waits can disappear between samples. For durable incident analysis, correlate logs, application traces, and repeated samples.

Savepoints and lock release

Locks acquired after a savepoint can be released when the transaction rolls back to that savepoint, while locks acquired before it remain. This can be useful for controlled error recovery inside a transaction, but it is not a substitute for short transaction scope. When application frameworks hide savepoints behind nested transaction APIs, make sure the team knows which database locks survive each rollback boundary.

Monitoring caution

Do not poll pg_locks at extremely high frequency without need. Reading it requires PostgreSQL to copy lock-manager state consistently, and monitoring itself has cost. Sample at a cadence appropriate to incident detection and combine it with wait events and query timing.

10. Cleanup and checks

sql · cleanup
ROLLBACK;DROP TABLE IF EXISTS app.ch07_lock_demo;

Check your understanding

  1. Why can pg_locks contain many rows while no session is blocked?
  2. Are ROW EXCLUSIVE and ROW SHARE row-level locks?
  3. Why might a row-lock waiter appear as waiting on a transaction ID rather than a tuple lock?
  4. When are transaction-level advisory locks preferable to session-level advisory locks?
  5. Do SIReadLocks block a concurrent UPDATE?
Review the answers

Many locks are compatible and routinely granted. ROW EXCLUSIVE/ROW SHARE are table-level mode names. Row locks are stored in tuple headers, so waiters commonly wait on the holder transaction ID. Transaction-level advisory locks release automatically on commit/rollback, which is safer for bounded business operations. SIReadLocks do not block writers; they support Serializable conflict detection.

11. Production judgment and bridge

Diagnose contention from waiting sessions and blockers, not lock counts alone. Keep lock scopes short, acquire resources in consistent order, use explicit locks only where the invariant needs them, and treat advisory key design as part of your application protocol. Lesson 4 deliberately creates a deadlock and then turns lock-aware semantics into a worker-queue pattern with NOWAIT, SKIP LOCKED, and bounded timeouts.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.