Home
ArenaGraphSignalTopics
Back to Feed

PostgreSQL MVCC Internals

Last Updated • 8d ago
PostgreSQL MVCC Internals

PostgreSQL MVCC Internals: The Deep Mechanics of Tuple Visibility, VACUUM, HOT Optimization, and Transaction Isolation

In high-throughput relational database engineering, concurrency control is the architectural fulcrum that dictates latency, write amplification, transaction isolation guarantees, and storage overhead.

While classical database engines historically relied on Two-Phase Locking (2PL)—where read locks actively stall write transactions—modern relational engines universally implement Multi-Version Concurrency Control (MVCC).

However, PostgreSQL's implementation of MVCC differs radically from engines like MySQL (InnoDB) or Oracle. Rather than maintaining a single canonical row in place while writing delta diffs to an Undo Log, PostgreSQL writes every new row revision directly to the Heap Table Pages as an immutable physical tuple.

Interactive Blueprint
Rendering diagram...

This structural decision enables instantaneous transaction rollbacks and lock-free concurrent reads, but introduces profound engineering trade-offs: Heap Bloat, Write Amplification, Line Pointer Redirection Chains, and the operational necessity of VACUUM and Transaction ID Freeze Cycles.

This technical essay explores the end-to-end internals of PostgreSQL's MVCC engine: from 8KB binary heap page layouts and HeapTupleSatisfiesMVCC visibility algorithms, to Heap-Only Tuple (HOT) optimization, 32-bit transaction ID wraparound prevention, and lock-free Serializable Snapshot Isolation (SSI).


1. The Core Concurrency Problem & Heap Page Anatomy

The S2PL Bottleneck vs. The MVCC Axiom

In classical Strict Two-Phase Locking (S2PL):

  • To read a row, a transaction acquires a Shared Lock (). Multiple readers can hold -locks simultaneously.
  • To write or update a row, a transaction must acquire an Exclusive Lock ().
  • An -lock is incompatible with both -locks and other -locks.
Interactive Blueprint
Rendering diagram...

In high-throughput OLTP workloads (such as real-time financial ledger balancing or high-frequency telemetry tracking), S2PL creates catastrophic lock queuing, cascading deadlocks, and severe tail latencies.

Multi-Version Concurrency Control (MVCC) solves this bottleneck via a simple fundamental axiom:

Instead of mutating a memory location in place under an exclusive lock, an UPDATE in PostgreSQL is architecturally decomposed into an INSERT of a new row version coupled with a logical DELETE of the old row version. Multiple physical versions (called tuples) of the same logical row coexist simultaneously inside the table files on disk.

When a query executes, the database engine passes each candidate tuple through a deterministic mathematical predicate—the Visibility Routine—which evaluates the tuple's creation and deletion metadata against the active transaction's point-in-time Snapshot.


The Physical Anatomy of a PostgreSQL 8KB Heap Page

To understand how tuples are addressed, locked, and garbage-collected, we must inspect the binary layout of a PostgreSQL table file.

Every table in PostgreSQL is stored on disk inside the database directory ($PGDATA/base/<db_oid>/<relfilenode>) as an array of fixed-size 8192-byte (8KB) blocks known as Heap Pages.

An 8KB heap page is structured with a bi-directional converging memory architecture:

Interactive Blueprint
Rendering diagram...

1. PageHeaderData (24 Bytes)

The page header contains fundamental synchronization, free-space boundary, and checksum metadata:

c
Loading code editor...

2. The Line Pointer Array (ItemIdData — 4 Bytes per entry)

Immediately following the 24-byte page header is an array of Line Pointers (ItemIdData). Line pointers are 1-indexed (ItemId[1], ItemId[2], etc.) and grow downwards from offset 24 toward the center of the page (pd_lower).

Each 4-byte line pointer is bit-packed into three fields:

  • lp_off (15 bits): Byte offset from the start of the page to the physical start of the tuple.
  • lp_flags (2 bits): State flag of the line pointer:
    • LP_UNUSED (0): Line pointer is unused/free.
    • LP_NORMAL (1): Line pointer points to a valid live/dead physical tuple.
    • LP_REDIRECT (2): Line pointer points to another ItemId on the same page (used for HOT update chains).
    • LP_DEAD (3): Line pointer's tuple has been removed by vacuuming; the line pointer remains temporarily until index pointers are cleaned.
  • lp_len (15 bits): Byte length of the physical tuple.

3. The Physical Tuples (HeapTupleHeaderData + Data)

Actual row data is allocated starting from the bottom of the page (offset 8192) and grows upwards toward pd_upper.

When a new row is inserted:

  1. Space is allocated at pd_upper - tuple_size.
  2. A new 4-byte line pointer entry is added at pd_lower.
  3. pd_lower increments by 4, and pd_upper decrements by tuple_size.

Why This Indirection Architecture Exists: External references (such as B-Tree Index leaf nodes or foreign key references) never point directly to a byte offset inside a page. Instead, they store a 6-byte Tuple Identifier (TID / ItemPointerData):

When PostgreSQL needs to defragment a page (e.g., during VACUUM or HOT pruning), it can slide physical tuples around to coalesce free space without updating a single index entry on the entire table, because the (BlockNumber, OffsetNumber) line pointer index remains unchanged!


The HeapTupleHeaderData Struct Breakdown

Every physical tuple written to a heap page begins with an immutable header: HeapTupleHeaderData (typically 23 bytes, padded to 24 bytes on 64-bit architectures).

Interactive Blueprint
Rendering diagram...

Let us examine the exact C-struct fields defined in src/include/access/htup_details.h:

c
Loading code editor...

1. t_xmin (4 Bytes / 32-bit Integer)

The Transaction ID (XID) of the transaction that inserted this tuple via INSERT or created it as a new version via UPDATE.

2. t_xmax (4 Bytes / 32-bit Integer)

The Transaction ID of the transaction that deleted this tuple via DELETE or replaced it via UPDATE.

  • If the tuple has not been deleted or updated, t_xmax = 0 (InvalidTransactionId).
  • t_xmax is also used to store the XID of a transaction holding a row-level lock (e.g., SELECT ... FOR UPDATE or SELECT ... FOR SHARE).

3. t_cid (Command Identifier — 4 Bytes)

A 32-bit counter starting at 0 that tracks which internal SQL statement within a multi-statement transaction created or deleted the tuple. This allows a transaction to see changes made by its own earlier statements while maintaining visibility isolation against concurrent transactions.

4. t_ctid (ItemPointerData — 6 Bytes)

The physical locator (BlockNumber, OffsetNumber) of the tuple:

  • Unmodified Tuple: If this is the latest live version of the row, t_ctid points to itself (e.g., (42, 1) points to Block 42, Line Pointer 1).
  • Updated Tuple: If this row version has been updated, t_ctid points forward to the new version of the tuple (e.g., (42, 2) or (85, 3)), forming a unidirectional linked list known as the Tuple Update Chain.
Interactive Blueprint
Rendering diagram...

The t_infomask Bitflags & Hint Bits

Checking the transaction status (committed vs. aborted vs. in-progress) of t_xmin and t_xmax requires reading the Commit Log (CLOG / pg_xact) on disk. Because checking the CLOG for millions of tuples during a table scan would cause extreme I/O and shared memory lock contention, PostgreSQL utilizes Hint Bits stored directly in t_infomask:

Constant FlagBitmask ValueArchitectural Meaning
HEAP_XMIN_COMMITTED0x0100The transaction that created this tuple (t_xmin) is committed. CLOG lookup is bypassed!
HEAP_XMIN_INVALID0x0200The transaction that created this tuple (t_xmin) aborted or crashed. Tuple is permanently dead.
HEAP_XMIN_FROZEN0x0300(0x0100 | 0x0200) Tuple has been frozen by VACUUM. It is visible to all present and future transactions.
HEAP_XMAX_COMMITTED0x0400The transaction that deleted/updated this tuple (t_xmax) is committed.
HEAP_XMAX_INVALID0x0800The transaction that attempted to delete this tuple aborted, or t_xmax = 0.
HEAP_XMAX_IS_MULTI0x1000t_xmax is a MultiXactId (multiple concurrent transactions hold shared locks on this tuple).
HEAP_HOT_UPDATED0x4000(In t_infomask2) This tuple is an old version whose replacement is a Heap-Only Tuple (HOT).
HEAP_ONLY_TUPLE0x2000(In t_infomask2) This tuple is a HOT tuple; it has no direct root index pointers pointing to it.

The First-Reader Mutex Mutation: When a transaction first reads a raw tuple whose t_infomask hint bits are unset (0), it queries the shared memory CLOG buffer. Once it determines that t_xmin committed, the reader transaction modifies the heap page in shared memory by setting the HEAP_XMIN_COMMITTED bit and marks the shared buffer dirty!

This means that in PostgreSQL, even a read-only SELECT query can generate WAL logs and dirty shared buffer pages if it is the first query to visit recently committed tuples.

In the next module, we will explore the mathematical logic of the Snapshot Engine and the HeapTupleSatisfiesMVCC visibility algorithm.


2. The Exact Tuple Visibility Algorithm & Snapshot Mechanics

At any given millisecond, a busy PostgreSQL instance may have thousands of concurrent transactions executing INSERT, UPDATE, and DELETE operations. How does a single read query determine—without acquiring locks—precisely which tuple versions are visible and which must be hidden?

The answer lies in the synchronization between two core subsystems:

  1. The Commit Log (CLOG / pg_xact), which tracks the physical lifecycle state of every Transaction ID.
  2. The Database Snapshot (SnapshotData), which captures a point-in-time logical boundary of the cluster.
Interactive Blueprint
Rendering diagram...

The Commit Log (CLOG / pg_xact) Architecture

Every transaction that mutates state is assigned a monotonically increasing 32-bit TransactionId (XID). PostgreSQL records the status of every XID in the Commit Log (CLOG), physically located in the $PGDATA/pg_xact/ directory.

To maximize density, the status of each transaction is packed into 2 bits:

Interactive Blueprint
Rendering diagram...
  • 1 Byte stores the status of 4 transactions.
  • 1 Standard 8KB Page stores the status of .
  • A 256KB file segment can store the status of over 1 Million transactions.

The CLOG is maintained in shared memory through an LRU cache buffer (the CLOG SLRU). When a transaction executes COMMIT, PostgreSQL writes a commit record to the Write-Ahead Log (WAL), flushes WAL to disk, and then atomic-bitwise flips the transaction's 2 bits in the CLOG buffer from 00 (IN_PROGRESS) to 01 (COMMITTED).


Anatomy of a PostgreSQL Snapshot (SnapshotData)

When a query begins execution (under READ COMMITTED) or when a transaction begins (under REPEATABLE READ), PostgreSQL takes a Snapshot.

A snapshot is not a physical copy of data; it is an ultra-compact memory structure (SnapshotData) defined in src/include/utils/snapshot.h:

c
Loading code editor...

In textual diagnostics (such as txid_current_snapshot()), a snapshot is formatted as:

For example, given 100:108:102,105:

  • : All transactions with had already finished (committed or aborted) when this snapshot was taken.
  • : The next unassigned XID is 108. All transactions with had not yet started and are strictly in the future.
  • : At the instant the snapshot was created, XIDs 102 and 105 were actively running. XIDs 100, 101, 103, 104, 106, and 107 had already committed or aborted.
Interactive Blueprint
Rendering diagram...

The Mathematical Visibility Logic: HeapTupleSatisfiesMVCC

When scanning table blocks, the storage engine invokes HeapTupleSatisfiesMVCC() (located in src/backend/access/heap/heapam_visibility.c) for every physical tuple.

The visibility algorithm executes in two distinct phases: Evaluating t_xmin (Creation) and Evaluating t_xmax (Deletion).

Interactive Blueprint
Rendering diagram...

Phase 1: Is the Creator (t_xmin) Visible?

  1. Frozen Tuples: If t_infomask & HEAP_XMIN_FROZEN is true, the tuple was created in the distant past by a vacuum-frozen transaction. t_xmin is guaranteed visible.
  2. Aborted Creator: If t_infomask & HEAP_XMIN_INVALID is true, the creating transaction aborted or crashed. The tuple is immediately invisible.
  3. Current Transaction: If t_xmin == GetCurrentTransactionId():
    • The tuple was inserted by the current transaction.
    • If the tuple was inserted in a later command within the same transaction (), it is not yet visible.
    • If , it is visible.
  4. Active in Snapshot:
    • If : The creator started after our snapshot was taken. Invisible.
    • If : The creator was actively in-progress when our snapshot was taken. Invisible.
  5. Committed Past Transaction:
    • If or committed before our snapshot: t_xmin is visible. Proceed to Phase 2.

Interactive Blueprint
Rendering diagram...

Phase 2: Has the Tuple Been Deleted (t_xmax)?

Once t_xmin is verified as visible, we must determine if t_xmax has deleted or replaced this tuple version:

  1. Never Deleted: If t_xmax == 0 or t_infomask & HEAP_XMAX_INVALID is true, the tuple has never been deleted (or the deleting transaction aborted). The tuple is visible.
  2. Row-Level Locks: If t_infomask & HEAP_XMAX_LOCK_ONLY is true (e.g., SELECT ... FOR SHARE), t_xmax represents a lock, not a deletion. The tuple is visible.
  3. Deleted by Current Transaction: If t_xmax == GetCurrentTransactionId():
    • If deleted by an earlier command within the current transaction (), the tuple is invisible (deleted).
    • If deleted in the current active command (), the tuple is visible.
  4. Deleted in Concurrent/Future Transaction:
    • If : The deletion occurred in a transaction that started after our snapshot. To our snapshot, the deletion has not happened yet! The tuple is visible.
    • If : The deleting transaction was still running when our snapshot was taken. The tuple is visible.
  5. Committed Prior Deletion:
    • If and t_xmax is committed in the CLOG: The tuple was deleted before our snapshot was created. The tuple is dead and invisible.

The Subtransaction Cache Overflow Hazard

PostgreSQL implements SQL Savepoints using Subtransactions. When a savepoint is created (SAVEPOINT my_savepoint), the transaction is assigned a 32-bit SubTransactionId.

  • Inside SnapshotData, PostgreSQL allocates a fixed-size cache of up to 64 subtransactions (subxip[64]).
  • If a transaction executes more than 64 savepoints (a common pattern in nested ORM transactions like Django or Hibernate), the subtransaction array overflows (suboverflowed = true).
Interactive Blueprint
Rendering diagram...

In the next module, we will examine the physical consequences of the tuple versioning model: Write Amplification and Heap-Only Tuple (HOT) Optimization.


3. The Write Amplification Problem & HOT (Heap-Only Tuples) Updates

While PostgreSQL's in-heap MVCC model provides instantaneous rollbacks and lock-free concurrent reads, it exposes a severe physical vulnerability known as Secondary Index Write Amplification.

Interactive Blueprint
Rendering diagram...

The Anatomy of Non-HOT Write Amplification

Consider a table with 1 primary key and 5 secondary indexes (e.g., email, status, organization_id, created_at, phone):

  1. An application executes:
    sql
    Loading code editor...
  2. Because the update cannot mutate the physical row in place, PostgreSQL creates a new tuple version at a different location (e.g., Block 4, Line Pointer 7).
  3. The new tuple has a new Tuple ID (TID): (4, 7).
  4. In standard MVCC, every single index on the table must be updated to insert a new index pointer pointing to (4, 7), even though last_active_at is not part of the primary key, email, or status indexes!

This causes devastating performance degradation:

  • B-Tree Index Bloat: Indexes swell in size, exceeding RAM and causing cache thrashing.
  • Leaf Node Splitting: Cascading B-Tree splits incur heavy WAL logging.
  • Random Disk I/O: Modifying multiple indexes dirties disparate memory pages across the buffer pool.

The Heap-Only Tuples (HOT) Breakthrough

Introduced in PostgreSQL 8.3, Heap-Only Tuples (HOT) is an architectural optimization designed to completely eliminate index updates for non-indexed column modifications.

Interactive Blueprint
Rendering diagram...

The Two Inviolable Rules for HOT Eligibility

An UPDATE operation is eligible for HOT optimization if and only if:

  1. No Indexed Column is Modified: The UPDATE statement does not alter any column that is referenced by any index, expression index, or foreign key on the table.
  2. Same-Page Free Space: The 8KB heap page containing the old tuple version must have sufficient free space between pd_lower and pd_upper to store the new tuple version.

Mechanics of the Line Pointer Redirection Chain

When a HOT update occurs:

  1. The new tuple is written to the same 8KB heap page.
  2. The new tuple's t_infomask2 has the HEAP_ONLY_TUPLE (0x2000) flag set. This signifies that no index in the entire database points directly to this tuple.
  3. The old tuple's t_infomask2 has the HEAP_HOT_UPDATED (0x4000) flag set, and its t_ctid is updated to point to the new tuple's line pointer.
  4. The B-Tree indexes are not touched at all! The index leaf node continues to point to the root line pointer (ItemId[1]).

When an index scan traverses to (Block 1, ItemId[1]), the storage engine detects the LP_REDIRECT or HEAP_HOT_UPDATED chain and follows the line pointers in-memory to find the visible tuple version.


Opportunistic In-Place Page Pruning (heap_page_prune)

One of the most elegant mechanisms in PostgreSQL is that HOT chains do not require the background VACUUM worker to be cleaned up. Instead, PostgreSQL performs opportunistic micro-pruning during normal sequential or index scans!

When any backend process reads an 8KB page (via SELECT or UPDATE):

  1. The engine checks if the page contains dead intermediate HOT tuples whose t_xmax is older than all active transactions.
  2. If dead tuples exist, heap_page_prune() executes immediately inside the shared buffer:
    • It physically unlinks the dead intermediate tuples.
    • It rewrites the root line pointer (ItemId[1]) from LP_NORMAL to LP_REDIRECT, pointing directly to the live tuple (ItemId[3]).
    • It marks intermediate line pointers as LP_UNUSED.
    • It shifts remaining tuples toward the bottom of the page (PageRepairFragmentation) to coalesce free space into a contiguous gap (pd_upper - pd_lower).
Interactive Blueprint
Rendering diagram...

This ensures that update-heavy tables can continuously update rows millions of times without accumulating dead tuple bloat or touching B-Tree indexes!


Tuning fillfactor for Maximum HOT Optimization

By default, PostgreSQL tables have a fillfactor = 100, meaning an INSERT will fill an 8KB page up to 100% capacity before allocating the next page.

If an 8KB page is 100% full, subsequent UPDATE operations cannot perform HOT updates because there is no room on the same page for the new tuple! The update falls back to a non-HOT update, writing the tuple to a different page and dirtying every index.

To maximize HOT optimization on update-heavy tables (e.g., status flags, counter increments, user session updates), administrators reduce the table's fillfactor:

sql
Loading code editor...
Interactive Blueprint
Rendering diagram...

In the next module, we explore the engine's primary garbage collector: VACUUM Internals, Bloat, and the 32-bit Transaction ID Wraparound Catastrophe.


4. VACUUM Internals, Bloat & Transaction ID Wraparound

Because PostgreSQL never overwrites old data in place, every DELETE and non-HOT UPDATE leaves behind an obsolete physical record known as a Dead Tuple. Without aggressive background reclamation, database files grow monotonically, exhausting disk space, polluting shared memory buffers, and degrading sequential scan performance—a pathological condition known as Table Bloat.

Interactive Blueprint
Rendering diagram...

The Lifecycle of a Dead Tuple & The OldestXmin Horizon

When does a dead tuple become eligible for physical removal by VACUUM?

A dead tuple cannot be reclaimed merely because its deleting transaction (t_xmax) committed. If a long-running analytical query or an uncommitted transaction holds a snapshot created before t_xmax committed, that reader must still be able to see the old tuple version!

where is the lowest active transaction ID or snapshot horizon across the entire PostgreSQL cluster (including active read transactions, replication slots, and prepared transactions).

Interactive Blueprint
Rendering diagram...

The idle in transaction Hazard: If a developer leaves a database connection in an idle in transaction state (e.g., executing a BEGIN without a COMMIT), or if a read-only replication slot falls behind, remains pinned indefinitely. VACUUM is paralyzed across all tables in the entire database, causing exponential table and index bloat.


Lazy VACUUM vs. Full VACUUM (VACUUM FULL)

PostgreSQL provides two radically different mechanisms for space management:

AttributeLazy VACUUM (VACUUM)Full VACUUM (VACUUM FULL)Online Repack (pg_repack)
Lock Level AcquiredShareUpdateExclusiveLock (Allows concurrent SELECT, INSERT, UPDATE, DELETE)AccessExclusiveLock (Blocks ALL reads and writes!)Brief AccessExclusiveLock at start and swap
Physical MechanismReclaims dead tuple space in-place; marks line pointers LP_UNUSEDRewrites entire table into a brand new file on diskRebuilds table via shadow table & write triggers
Disk File TruncationOnly truncates trailing empty pages at the very end of the fileCompletely shrinks disk file to exact live data sizeCompletely shrinks disk file to live data size
Index ProcessingScans and unlinks index keys in separate passesRebuilds all indexes from scratchRebuilds indexes on shadow table
Production SafetySafe for production online executionDangerous (Can lock production tables for hours)Safe for high-concurrency production
Interactive Blueprint
Rendering diagram...

Auxiliary Storage Engines: The Visibility Map (VM) & Free Space Map (FSM)

To prevent VACUUM and INSERT operations from performing expensive full-table scans, PostgreSQL maintains two auxiliary binary fork files alongside every table:

1. The Visibility Map (<relfilenode>_vm)

The Visibility Map stores 2 bits per 8KB heap page:

  • Bit 0 (ALL_VISIBLE): Set if all tuples on the 8KB heap page are known to be visible to all active and future transactions.
    • The Index-Only Scan Superpower: When a query scans a B-Tree index, if the target page has the ALL_VISIBLE bit set in the VM, PostgreSQL does not visit the heap page at all! It returns the data directly from the B-Tree leaf node, cutting disk I/O in half.
  • Bit 1 (ALL_FROZEN): Set if all tuples on the page have been frozen. Subsequent VACUUM runs skip this page entirely, saving vast amounts of I/O on historic append-only data.

2. The Free Space Map (<relfilenode>_fsm)

The Free Space Map organizes the available free space of every page into a binary search tree. When an INSERT requires a page with at least bytes of free space, it queries the FSM in time rather than scanning the table sequentially.


The 32-Bit Transaction ID Wraparound Catastrophe

PostgreSQL's use of 32-bit transaction IDs introduces one of the most critical operational hazards in database engineering: Transaction ID Wraparound.

A 32-bit unsigned integer can represent .

Interactive Blueprint
Rendering diagram...

PostgreSQL interprets XID ordering using modular circular arithmetic:

At any point in time:

  • Exactly (2.14 Billion) XIDs are in the past (visible if committed).
  • Exactly (2.14 Billion) XIDs are in the future (strictly invisible).

The Catastrophe Scenario

Suppose a table contains a row inserted at . If the database executes 2.14 Billion subsequent transactions without freezing that row:

  1. The difference exceeds .
  2. The comparison flips: is mathematically interpreted as being in the FUTURE!
  3. The row instantaneously vanishes from all SELECT queries, resulting in catastrophic, silent database-wide data loss!

Freezing Tuples & Autovacuum Anti-Wraparound

To prevent historic data from vanishing, PostgreSQL implements Tuple Freezing:

  1. When VACUUM inspects a tuple whose t_xmin is older than vacuum_freeze_min_age (default 50 Million transactions), it freezes the tuple.
  2. It sets the HEAP_XMIN_FROZEN (0x0300) bitmask in t_infomask (or replaces t_xmin with FrozenTransactionId = 2).
  3. Mathematically, FrozenTransactionId is defined to be older than all past, present, and future transaction IDs. It never participates in modular arithmetic comparisons and remains permanently visible.
Interactive Blueprint
Rendering diagram...

The Emergency Failsafe Horizon:

In the next module, we explore the formal guarantees of concurrency: SQL Transaction Isolation Levels and Serializable Snapshot Isolation (SSI).


5. SQL Transaction Isolation Levels & Serializable Snapshot Isolation (SSI)

In distributed and relational systems, an Isolation Level defines the degree to which the modifications made by one transaction are isolated from the concurrent operations of other transactions.

While the ANSI SQL-92 standard attempted to define isolation levels in terms of three classical phenomena (Dirty Read, Non-Repeatable Read, Phantom Read), computer scientists (notably Berenson et al. in their famous 1995 critique "A Critique of ANSI SQL Isolation Levels") proved that the ANSI definitions are fatally incomplete when applied to multi-version engines.

PostgreSQL implements three physical isolation levels on top of its MVCC engine:

Interactive Blueprint
Rendering diagram...

PostgreSQL’s Three Physical Isolation Levels

1. Read Committed (PostgreSQL Default)

  • Snapshot Lifecycle: A brand new snapshot is generated at the start of every individual SQL query within the transaction.
  • If Transaction A executes SELECT count(*) FROM orders, updates occur in concurrent Transaction B, and Transaction A runs SELECT count(*) FROM orders again within the same transaction, the two queries will observe different results (Non-Repeatable Read).
  • The EvalPlanQual Re-Evaluation Quirk: If an UPDATE or SELECT ... FOR UPDATE attempts to lock a row that was concurrently modified by another transaction that committed:
    • PostgreSQL does not abort the transaction.
    • Instead, it waits for the concurrent transaction to commit, fetches the newest committed version of the row, and re-evaluates the WHERE clause against the updated tuple before applying the modification!

2. Repeatable Read

  • Snapshot Lifecycle: A single snapshot is created at the start of the first non-transaction-control statement and remains frozen throughout the entire transaction.
  • All queries within the transaction observe an identical point-in-time image of the database, regardless of how many concurrent transactions commit.
  • The First-Committer-Wins Rule: If Transaction A attempts to UPDATE or DELETE a tuple that was modified and committed by a concurrent transaction after Transaction A's snapshot was taken:
    text
    Loading code editor...
    PostgreSQL immediately rolls back Transaction A to prevent lost updates.

The Write Skew Anomaly: Why Repeatable Read is NOT Serializable

While REPEATABLE READ prevents dirty reads, non-repeatable reads, and phantom reads, it fails to prevent the Write Skew anomaly.

Interactive Blueprint
Rendering diagram...

Why Did Write Skew Occur?

  1. Both transactions read an overlapping dataset (the count of doctors on call).
  2. Each transaction made an update based on a premise verified in its own snapshot.
  3. Because modified Alice and modified Bob, their write sets were completely disjoint.
  4. The First-Committer-Wins rule was not triggered (neither updated the other's tuple).
  5. Both transactions committed cleanly, but the resulting database state violates the business invariant constraint!

Serializable Snapshot Isolation (SSI): Lock-Free True Serializability

Before PostgreSQL 9.1, preventing write skew required developers to manually acquire pessimistic locks (SELECT ... FOR UPDATE) or rely on external distributed lock managers.

In 2008, Michael J. Cahill, Uwe Röhm, and Alan D. Fekete published a revolutionary algorithm: Serializable Snapshot Isolation (SSI), implemented in PostgreSQL by Dan Ports and Kevin Grittner.

SSI provides true serializability with zero read locking! Transactions execute at full Snapshot Isolation speed while an in-memory graph analyzer monitors for dangerous conflict structures.

Interactive Blueprint
Rendering diagram...

The Mathematical Theory of -Antidependencies & Danger Cycles

In serializability theory, a Dependency Graph (Serialization Graph) consists of transactions as nodes and conflict dependencies as directed edges:

  1. -dependency: writes a tuple version, and overwrites it ().
  2. -dependency: writes a tuple version, and reads it ().
  3. -antidependency (Write-After-Read): reads a tuple version , and concurrent transaction writes a newer version that would have changed 's read result had executed first ().

The Fundamental SSI Theorem (Fekete et al.): In a Snapshot Isolation execution, a non-serializable anomaly occurs if and only if the dependency graph contains a cycle with two consecutive -antidependency edges!

The central transaction in this sequence is designated as the Pivot Transaction.

Interactive Blueprint
Rendering diagram...

In the on-call doctor example under SERIALIZABLE:

  1. read Bob before updated Bob .
  2. read Alice before updated Alice .
  3. When attempts to commit, PostgreSQL's SSI engine detects the two consecutive -edges forming a cycle ().
  4. PostgreSQL immediately aborts the pivot transaction (), throwing:
    text
    Loading code editor...
  5. The business invariant is 100% mathematically preserved!

The SIREAD Lock Hierarchy in Shared Memory

How does PostgreSQL track what a query read without acquiring locks? Through SIREAD Locks stored in the PREDICATELOCK shared memory hash table.

  • SIREAD Locks Do NOT Block Anything: An SIREAD lock is a pure in-memory tracking flag. A transaction holding an SIREAD lock will never cause another query to wait or stall.
  • Predicate Lock Granularity Hierarchy:
    1. Tuple-Level SIREAD: Tracks a specific (BlockNumber, OffsetNumber).
    2. Page-Level SIREAD: If a transaction reads many tuples on a page (e.g., during an index range scan), PostgreSQL automatically promotes the locks to a single Page-Level SIREAD lock.
    3. Relation-Level SIREAD: If a sequential scan reads an entire table, it promotes to a single Relation-Level SIREAD lock.
Interactive Blueprint
Rendering diagram...

This lock promotion prevents shared memory exhaustion while maintaining rock-solid serializability guarantees.


Production Architecture: Designing Application Retry Loops

Because SSI uses optimistic concurrency control, applications using SERIALIZABLE isolation must implement an automated transaction retry loop to handle SQLSTATE 40001 (serialization_failure):

python
Loading code editor...

Low-Level Diagnostic SQL Toolkit

1. Inspecting Live Heap Page Headers with pageinspect

The pageinspect extension allows you to inspect raw byte-level PageHeaderData and HeapTupleHeaderData directly from SQL:

sql
Loading code editor...

2. Measuring Exact Table and Index Bloat with pgstattuple

Rather than relying on rough estimates from pg_stat_user_tables, pgstattuple performs a physical block scan to measure dead tuple overhead:

sql
Loading code editor...

3. Auditing the Visibility Map with pg_visibility

sql
Loading code editor...

Architectural Comparison Table: Storage Engine Concurrency Control

DimensionPostgreSQL (Heap Multi-Versioning)MySQL InnoDB (Undo Log Segment)SQLite (WAL Shadow Pages)CockroachDB / TiDB (LSM MVCC Keys)
Tuple Version PlacementDirectly in Heap Pages (8KB blocks)Canonical row in Clustered Index; Diffs in Undo LogShadow page frames in -wal fileMVCC timestamp appended to Key in LSM-Tree (key@t1)
Transaction Rollback Complexity Instant (Mark XID aborted in CLOG) Linear (Must replay Undo Log in reverse) Instant (Drop uncommitted WAL frames) Instant (Drop transaction record)
Garbage Collection MechanismVACUUM worker scans heap & indexesBackground Master Purge thread clears Undo SegmentsCheckpoint merges WAL frames to main DBCompaction filters drop obsolete MVCC timestamps
Write Amplification on Non-Indexed UPDATEHigh (mitigated by HOT line pointer chains)Very Low (only mutates clustered row & appends undo log)Moderate (writes entire 4KB dirty page to WAL)Moderate (writes new versioned key-value pair to MemTable)
SELECT count(*) Full Table ScanMandatory (Must verify visibility of every tuple)Instantaneous if cached in metadata, else index scanFull table B-Tree traversalDistributed Scan / Statistics Table
True Serializability (Lock-Free)Serializable Snapshot Isolation (SSI) via SIREAD graph analysisPessimistic 2PL Next-Key Locks (Blocks concurrent inserts)Single-writer serialization (Database lock)CockroachDB SSI / Parallel Commits

Frequently Asked Questions (FAQs)

1. Why does SELECT count(*) in PostgreSQL require a sequential scan instead of reading a single metadata integer?

In PostgreSQL's MVCC architecture, there is no such thing as a single universal row count.

At any given millisecond:

  • Transaction A may have inserted 10 rows that have not committed yet (visible only to Transaction A).
  • Transaction B may have deleted 5 rows that are committed to new snapshots but must remain visible to long-running Transaction C.

Because whether a given tuple exists depends entirely on the querying transaction's point-in-time Snapshot, PostgreSQL must scan the table (or the Visibility Map in an Index-Only Scan) to evaluate the HeapTupleSatisfiesMVCC visibility rules for every individual tuple. Engines like MySQL InnoDB store a single metadata count only for MyISAM tables (which use non-MVCC table-level locking).


2. What causes autovacuum to fall behind on high-throughput databases, and how do you tune it?

By default, PostgreSQL severely throttles autovacuum to prevent background disk I/O from interfering with user queries.

Autovacuum operates on a cost-budget loop:

  • Every page read from shared buffers costs vacuum_cost_page_hit (default 1).
  • Every page read from disk costs vacuum_cost_page_miss (default 2).
  • Every dirty page written to disk costs vacuum_cost_page_dirty (default 20).
  • When cumulative cost reaches autovacuum_vacuum_cost_limit (default 200), the worker sleeps for autovacuum_vacuum_cost_delay (default 2ms).

On modern NVMe SSDs, the default cost_limit = 200 limits vacuuming throughput to a meager 10–20 MB/sec, causing dead tuples to accumulate faster than autovacuum can purge them.

Production Tuning Strategy:

ini
Loading code editor...

3. How do long-running transactions or unconsumed replication slots cause database-wide bloat?

VACUUM can only reclaim a dead tuple if its deletion transaction satisfies:

is determined by the oldest active transaction across the entire cluster. If an application holds an open transaction (e.g., an uncommitted BEGIN or a long ETL query), or if a logical replication slot is inactive (active = false), stops advancing.

As a result:

  • VACUUM cannot remove any dead tuples deleted after that pinned .
  • Table files swell uncontrollably.
  • New INSERT and UPDATE queries cannot reuse dead space, causing runaway disk exhaustion.

4. What is the operational difference between VACUUM, VACUUM FULL, and pg_repack?

  • VACUUM (Lazy): Reclaims dead tuple space in-place for reuse by future INSERT/UPDATE queries. It runs online concurrently with all reads and writes, but does not reduce the physical file size on disk.
  • VACUUM FULL: Rewrites the entire table into a brand new file on disk, returning unused disk space to the OS. However, it acquires an AccessExclusiveLock, blocking all reads and writes for the duration of the operation.
  • pg_repack: A popular open-source extension that rebuilds bloated tables and indexes online. It creates a shadow table, mirrors live updates via database triggers, and swaps the physical file handles with only a millisecond lock at completion.

5. Why can a read-only SELECT query generate WAL logs and dirty shared buffers?

When a table contains newly committed rows whose t_infomask hint bits are unset (0), the first SELECT query that reads those tuples must check the shared memory Commit Log (CLOG) to verify that t_xmin committed.

To prevent future queries from repeating this expensive CLOG lookup, the reader transaction modifies the heap page in shared memory by setting the HEAP_XMIN_COMMITTED bit. This marks the shared buffer as dirty. When the checkpoint background writer flushes the dirty buffer to disk, WAL records are generated—meaning a read query physically mutates database pages!


6. When should an engineering team choose SERIALIZABLE (SSI) over READ COMMITTED with advisory locks?

  • Choose READ COMMITTED with Explicit Locking (SELECT ... FOR UPDATE): When concurrency conflicts are localized to single rows (e.g., decrementing a specific user's account balance).
  • Choose SERIALIZABLE (SSI): When business invariant constraints span multiple disjoint rows or aggregate conditions (e.g., verifying that the sum of debit and credit rows across multiple ledger accounts equals zero, or preventing write skew in shift scheduling). Pessimistic row locking on disjoint rows cannot prevent phantom inserts or cross-table skew without dangerous table-level locks that cause deadlocks. SSI guarantees mathematical serializability while running lock-free.
EDITORIAL & AUTHOR NETWORK

Write for InitNode. Earn Proof of Work.

Unlike Medium or Dev.to, InitNode is built exclusively for senior software engineers, infrastructure architects, and systems builders. Every published blueprint is free of paywalls, indexed within seconds, and permanently linked to your verified engineering pedigree.

+250 PoW XP

Climb the Architect Leaderboard and unlock verified reputation badges.

Rich Math & Mermaid

First-class LaTeX math, responsive sequence diagrams, and syntax highlighting.

Instant Indexing

Automated real-time submission to Google Indexing and IndexNow APIs.

Own Your Audience

Readers subscribe directly to you; automated email dispatches on release.

No paywalls. No popups. Strictly high-signal engineering.