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)
- If
xminis our own transaction: visible if inserted by an earlier command (cmin< current command id). - If
xminis in the snapshot (>= xmaxor inxip): invisible, it was not finished when we looked. - Otherwise look up
xminin pg_xact: aborted means invisible. - Now the deleter:
xmaxinvalid, aborted, only a row lock, or in our snapshot means the row is still visible. A committedxmaxoutside 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
| Level | Snapshot |
|---|---|
| READ COMMITTED | new 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 READ | at the first statement of the transaction, not at BEGIN |
| SERIALIZABLE | like 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 countsactive_count/regd_count, andsnapXactCompletionCount.procarray.c:GetSnapshotData(). UnderProcArrayLockin shared mode it reads the dense arraysProcGlobal->xids,subxidStatesandstatusFlags(skipping backends flaggedPROC_IN_VACUUMorPROC_IN_LOGICAL_DECODING), setsMyProc->xmin, and on a standby usesKnownAssignedXidsGetAndSetXmin().GetSnapshotDataReuse()returns the previous result unchanged ifTransamVariables->xactCompletionCounthas 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(), andExportSnapshot()/ImportSnapshot()(files inpg_snapshots/).heapam_visibility.c:HeapTupleSatisfiesMVCC()andSetHintBits().
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.cimplementspg_current_snapshot()andpg_visible_in_snapshot()(tests:src/test/regress/sql/xid.sql,txid.sql).- Isolation specs
horizons.specandfk-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