DB Internals AtlasPostgreSQL 18.6 Home About

Transactions and MVCC

Snapshots and visibility

A snapshot is a tiny record of which transactions were finished at one moment. Every row version is checked against it to decide whether you can see it.

In plain words

When your query starts reading, PostgreSQL takes a snapshot: a photo of which transactions had finished and which were still running at that instant. It is not a copy of the data, only three facts: the oldest transaction still running (xmin), one past the newest transaction that had finished (xmax; anything from there on counts as not finished), and the list of transactions running in between (xip).

For every row version it reads, PostgreSQL asks two questions. Was the transaction that created it finished and committed according to my photo? Was the transaction that deleted it finished and committed according to my photo? You see the row only if the answers are "yes" and "no".

When the photo is taken depends on the isolation level. In READ COMMITTED (the default) every statement takes a fresh photo, so a second SELECT can see newly committed data. In REPEATABLE READ and SERIALIZABLE the first statement takes it and the whole transaction keeps using it. Try both with the controls below the picture.

For DBAs and operators

Where a snapshot comes from

GetSnapshotData() scans the proc array in shared memory: one slot per backend with its current top-level xid and up to 64 cached subtransaction xids. The result is xmin (lowest xid still running), xmax (latest completed xid + 1) and xip[]. You can print yours:

SELECT pg_current_snapshot();          -- e.g. 1201:1207:1201,1204
SELECT pg_current_xact_id_if_assigned(); -- NULL for read-only work
SELECT pg_visible_in_snapshot('1204'::xid8, pg_current_snapshot());

The visibility test (HeapTupleSatisfiesMVCC, simplified)

  1. If xmin is our own transaction: visible if inserted by an earlier command (cmin < current command id).
  2. If xmin is in the snapshot (>= xmax or in xip): invisible, it was not finished when we looked.
  3. Otherwise look up xmin in pg_xact: aborted means invisible.
  4. Now the deleter: xmax invalid, aborted, only a row lock, or in our snapshot means the row is still visible. A committed xmax outside the snapshot means it is gone for us.

Hint bits

Looking up pg_xact for every tuple would be slow, so the first reader that learns the outcome sets hint bits in the tuple header (HEAP_XMIN_COMMITTED, HEAP_XMIN_INVALID, HEAP_XMAX_COMMITTED, HEAP_XMAX_INVALID). Later readers skip the lookup. This is why a plain SELECT right after a bulk load can dirty pages and even write WAL, see Commit log.

When snapshots are taken

LevelSnapshot
READ COMMITTEDnew one for every statement (STABLE and IMMUTABLE functions reuse the calling statement's snapshot; each statement inside a VOLATILE function takes a fresh one)
REPEATABLE READat the first statement of the transaction, not at BEGIN
SERIALIZABLElike REPEATABLE READ, plus SSI predicate locks

The xmin horizon and cleanup

Each backend publishes the xmin of its oldest snapshot (backend_xmin in pg_stat_activity). VACUUM cannot remove a dead version that any published snapshot might still need, so one forgotten transaction blocks cleanup in every table of the database. The "snapshot too old" feature (old_snapshot_threshold) was removed in PostgreSQL 17; use idle_in_transaction_session_timeout or transaction_timeout (new in 17) instead.

SELECT pid, usename, state, backend_xid, backend_xmin,
       age(backend_xmin) AS horizon_age, xact_start
  FROM pg_stat_activity
 WHERE backend_xmin IS NOT NULL
 ORDER BY age(backend_xmin) DESC;

Snapshots can be shared: pg_export_snapshot() plus SET TRANSACTION SNAPSHOT '…' lets several sessions read exactly the same data, which is how parallel pg_dump -j stays consistent.

For developers: inside the source

Where it lives

  • snapshot.h: SnapshotData: xmin, xmax, xip/xcnt, subxip/subxcnt, suboverflowed, takenDuringRecovery, curcid, the reference counts active_count/regd_count, and snapXactCompletionCount.
  • procarray.c: GetSnapshotData(). Under ProcArrayLock in shared mode it reads the dense arrays ProcGlobal->xids, subxidStates and statusFlags (skipping backends flagged PROC_IN_VACUUM or PROC_IN_LOGICAL_DECODING), sets MyProc->xmin, and on a standby uses KnownAssignedXidsGetAndSetXmin(). GetSnapshotDataReuse() returns the previous result unchanged if TransamVariables->xactCompletionCount has not moved since, so repeated snapshots on a quiet system are almost free.
  • snapmgr.c: GetTransactionSnapshot(), GetLatestSnapshot(), the active stack (PushActiveSnapshot(), GetActiveSnapshot()), RegisterSnapshot(), SnapshotResetXmin(), XidInMVCCSnapshot(), and ExportSnapshot()/ImportSnapshot() (files in pg_snapshots/).
  • heapam_visibility.c: HeapTupleSatisfiesMVCC() and SetHintBits().

Statement versus transaction snapshot

The isolation level decision is a few lines in GetTransactionSnapshot() (abridged):

if (!FirstSnapshotSet) {
    if (IsolationUsesXactSnapshot())     /* RR or SERIALIZABLE */
        ... GetSerializableTransactionSnapshot() or GetSnapshotData(),
            copied and kept as FirstXactSnapshot ...
    FirstSnapshotSet = true;
}
if (IsolationUsesXactSnapshot())
    return CurrentSnapshot;            /* reuse */
CurrentSnapshot = GetSnapshotData(&CurrentSnapshotData);   /* READ COMMITTED */

Visibility and hint bits

HeapTupleSatisfiesMVCC() checks hint bits first, then XidInMVCCSnapshot(), then TransactionIdDidCommit() (pg_xact). Order matters: xact.c records the commit in pg_xact before clearing the xid from the proc array, so pg_xact may only be consulted once the snapshot says the transaction is no longer running (see the NOTE at the top of the file). SetHintBits() refuses to set a "committed" hint for an asynchronously committed transaction whose commit record is not yet flushed (XLogNeedsFlush(commitLSN)), then calls MarkBufferDirtyHint(). Sequential scans in page mode call HeapTupleSatisfiesVisibility() once per tuple in page_collect_tuples(), and skip it entirely on all-visible pages.

Horizons

ComputeXidHorizons() combines every backend's xmin into the cleanup horizons; pruning and VACUUM ask GlobalVisTestFor(rel) and GlobalVisTestIsRemovableXid(), which avoids taking ProcArrayLock for most tuples. At commit ProcArrayEndTransaction() clears the xid (often batched through ProcArrayGroupClearXid()) and bumps xactCompletionCount. The section "Interlocking Transaction Begin, Transaction End, and Snapshots" in access/transam/README explains why this is race-free.

Observe and experiment

  • xid8funcs.c implements pg_current_snapshot() and pg_visible_in_snapshot() (tests: src/test/regress/sql/xid.sql, txid.sql).
  • Isolation specs horizons.spec and fk-snapshot.spec; on standbys, src/test/recovery/t/021_row_visibility.pl.
  • pageinspect's heap_tuple_infomask_flags() shows whether a read set the hint bits.

Parameters that shape this

default_transaction_isolation transaction_isolation idle_in_transaction_session_timeout hot_standby_feedback transaction_buffers

Related topics

MVCC: row versions Isolation levels Commit log, hint bits and subtransactions VACUUM Hot standby and recovery conflicts