Reading PostgreSQL: MVCC Internals, from Snapshots to HeapTupleSatisfiesMVCC
How PostgreSQL decides which row version you see: tuple headers, snapshots, and a line-by-line walk through HeapTupleSatisfiesMVCC.
· 14 min read

A row is never overwritten: every write makes a new tuple version stamped with
xmin/xmax, and a reader's snapshot decides which version it can see.
In PostgreSQL Internals - Module 3, we looked at MVCC and isolation levels at the concept level; you can read it first for the overall idea. Here we go a level deeper and read the source: how writes stamp tuples (rows), how a snapshot is built, and then the function HeapTupleSatisfiesMVCC() that answers "Is this tuple visible to me?" - line by line, branch by branch.
Source version: all code and file paths in this post are from PostgreSQL 18 (branch
REL_18_STABLE)
Brain map
mindmap
root((MVCC))
Tuple versions
xmin, xmax, ctid, cid, infomask
Snapshot
xmin, xmax, xip, curcid
Writes
DMLs
HeapTupleSatisfiesMVCC
confirms visibility of a tuple
Isolation
READ COMMITTED / REPEATABLE READ
Cleanup
Dead tuples
Vacuuming
Freezing & XID wraparound
1. MVCC: the idea
Every write creates a new physical tuple version; older versions stay on the page.
Each version records who created it (
t_xmin) and who deleted or locked it (t_xmax).An UPDATE also counts as a delete: it sets
t_xmaxon the old version and writes a new version with the new values.Every reader process carries a snapshot: a frozen answer to "which transactions count as finished for me?". Once taken, it doesn't change; the isolation level decides how often a new one is taken (Section 5).
Visibility is a pure function of the tuple's
xmin/xmaxand the reader's snapshot:
visible =
xminis committed and finished per my snapshot, ANDxmaxis not (unset, aborted, lock-only, or still running per my snapshot)
As a result, readers never block writers and writers never block readers.
The cost: dead tuple versions accumulate and must be removed by VACUUM or page pruning; until then, scans have to skip them.
2. The end-to-end flow
Step 1: Write stamps the tuple. INSERT sets xmin; DELETE sets xmax; UPDATE sets xmax on the old version, inserts a new version with xmin, and links old → new through t_ctid. t_ctid is the tuple's physical address (block, line pointer); on an old version it points to the newer one, forming a chain from old to new.
CREATE EXTENSION IF NOT EXISTS pageinspect; -- lets us inspect the 8KB pages/blocks of a table
-- Sample users table
CREATE TABLE users (id INT, name TEXT, PRIMARY KEY(id));
-- Insert a row
INSERT INTO users VALUES (1, 'samba');
-- Inspect the inserted tuple in block 0
SELECT
lp AS line_pointer,
t_ctid AS physical_id,
t_xmin AS created_by_txn,
t_xmax AS deleted_by_txn
FROM heap_page_items(get_raw_page('users', 0));
line_pointer | physical_id | created_by_txn | deleted_by_txn
--------------+-------------+----------------+----------------
1 | (0,1) | 34549 | 0
-- Update the same row
UPDATE users SET name = 'siva' WHERE id = 1;
-- Inspect again
SELECT
lp AS line_pointer,
t_ctid AS physical_id,
t_xmin AS created_by_txn,
t_xmax AS deleted_by_txn
FROM heap_page_items(get_raw_page('users', 0));
line_pointer | physical_id | created_by_txn | deleted_by_txn
--------------+-------------+----------------+----------------
1 | (0,2) | 34549 | 34550 -- old version's t_ctid points to the new one
2 | (0,2) | 34550 | 0
Step 2: Reader takes a snapshot. xmax = latestCompletedXid + 1; xmin = oldest XID still running; xip[] = running XIDs in [xmin, xmax).
Transactions at snapshot time:
XID 1040 — committed
XID 1041 — IN PROGRESS ← goes into xip[]
XID 1042 — YOUR transaction (own XID is skipped, never in xip[])
XID 1043 — committed ← latestCompletedXid
XID 1044 — not started yet
Snapshot result:
xmin = 1041 (oldest still running)
xmax = 1044 (latestCompletedXid + 1)
xip = [1041]Step 3: Each tuple is tested against the snapshot. First the xmin half (was the insert done for me?), then the xmax half (was the delete done for me?).
Step 4: pg_xact is consulted only after the snapshot says "not in progress". The result is cached on the tuple as a hint bit. (pg_xact was called CLOG before PostgreSQL 10; this post says pg_xact throughout.)
Step 5: Isolation level = how often Step 2 happens. READ COMMITTED: every statement. REPEATABLE READ and above: once per transaction.
3. Key data structures
3.1. Tuple header: HeapTupleHeaderData
Source: src/include/access/htup_details.h
Field | Purpose |
|---|---|
| Inserting transaction ID |
| Deleting or locking transaction ID |
| Inserting or deleting command ID, or both (combo CID when |
| TID (tuple ID) of this tuple, or of the newer version |
| Flag bits including visibility hints, lock-only and multi flags |
3.2. t_infomask bits used for visibility
Source: src/include/access/htup_details.h
Bit | Meaning |
|---|---|
| xmax, if valid, is only a locker |
| t_xmin is committed |
| t_xmin is invalid/aborted |
|
|
| t_xmax is committed |
| t_xmax is invalid/aborted |
| t_xmax is a MultiXactId |
| this is the UPDATEd version of a row |
3.3. Snapshot: SnapshotData
Source: src/include/utils/snapshot.h
Field | Purpose |
|---|---|
| all XIDs (transaction IDs) < xmin are visible to me |
| all XIDs >= xmax are invisible to me |
| in-progress XIDs, all in |
| in-progress subtransaction XIDs; overflow flag |
| in my transaction, CIDs (command IDs) < curcid (current command ID) are visible |
| snapshot must be registered or active when used |
|
|
3.4. Special XIDs
Source: src/include/access/transam.h
InvalidTransactionId = 0, BootstrapTransactionId = 1, FrozenTransactionId = 2, FirstNormalTransactionId = 3.
4. Writes: how a tuple gets stamped
Source: src/backend/access/heap/heapam.c
heap_prepare_insert(): prepares a tuple for insertion and returns it. SetsHEAP_XMAX_INVALID, xmin = my XID, cid (cmin) = my CID, xmax = 0. If the tuple has external values or is larger thanTOAST_TUPLE_THRESHOLD, it hands the tuple toheap_toast_insert_or_update()to move large values out of line.heap_delete()marks the tuple deleted: sets xmax and cid (cmax) on the existing tuple, setst_ctidto itself. No data is removed.heap_update()writes a new tuple (xmin = my XID,HEAP_UPDATEDset) and updates the old tuple's header: xmax, cid (cmax), andt_ctid = heaptup->t_self(the new version).
5. Snapshots: how "now" is frozen
Every query calls GetTransactionSnapshot(). If the isolation level is REPEATABLE READ or higher (checked by IsolationUsesXactSnapshot()), the first snapshot is saved in FirstXactSnapshot and returned for every later call. Otherwise, each call runs GetSnapshotData() again, so READ COMMITTED gets a fresh snapshot for every query.
Source: src/backend/utils/time/snapmgr.c
IsolationUsesXactSnapshot()checksXactIsoLevel >= XACT_REPEATABLE_READ. Source:src/include/access/xact.hGetSnapshotData()holdsProcArrayLockinLW_SHARED;xmax = latestCompletedXid + 1; loops overProcGlobal->xids[]; setsMyProc->xmin. Source:src/backend/storage/ipc/procarray.c
/* procarray.c, GetSnapshotData(): which backends are skipped */
if (likely(xid == InvalidTransactionId))
continue; /* no XID assigned */
if (pgxactoff == mypgxactoff)
continue; /* our own XID */
if (!NormalTransactionIdPrecedes(xid, xmax))
continue; /* >= xmax: running anyway */
if (statusFlags & (PROC_IN_LOGICAL_DECODING | PROC_IN_VACUUM))
continue;
if (NormalTransactionIdPrecedes(xid, xmin))
xmin = xid;
xip[count++] = xid;
So the isolation level is not a separate mechanism in the visibility code, it only decides how often GetSnapshotData() runs.
6. Example: two sessions
Row v1 was inserted by XID 100 (committed). Session B has XID 101 and is running; XID 102 has committed.
Session A takes a snapshot, snapA: xmax = 103, xmin = 101, xip = [101].
Session B (XID 101) updates v1. The page now holds:
Tuple | xmin | xmax | t_ctid |
|---|---|---|---|
v1 | 100 | 101 | → v2 |
v2 | 101 | 0 | → v2 |
Session A runs a SELECT with snapA:
v1:
xmin100 < 101, so it's not in the snapshot; pg_xact says 100 committed → the insert happened for A.xmax101 is insnapA.xip→ the delete hasn't happened for A. Visible.v2:
xmin101 is insnapA.xip→ the insert hasn't happened for A. Invisible.
Now Session B commits and Session A queries again. The result depends on A's isolation level:
REPEATABLE READ: every query in the transaction uses the same snapshot,
snapA, so Session A still sees v1. Session B's commit in pg_xact is never consulted, because the snapshot check comes first.READ COMMITTED: every query gets a new snapshot, here
xmin = xmax = 103, emptyxip. v1'sxmax101 is finished and committed (the delete happened) → v1 is invisible. v2'sxmin101 is committed and belowxmax→ v2 is visible.
7. HeapTupleSatisfiesMVCC, line by line
7.1. Where it sits
Source:
src/backend/access/heap/heapam_visibility.c→HeapTupleSatisfiesMVCC().Call path:
heapgettup()/page_collect_tuples()inheapam.c→HeapTupleSatisfiesVisibility()(switch onsnapshot->snapshot_type) →HeapTupleSatisfiesMVCC()when the type isSNAPSHOT_MVCC.Inputs: the tuple (
HeapTuple htup), theSnapshot, and theBuffer(needed to set hint bits).Preconditions: the snapshot is registered or active (asserted); the caller holds a pin and at least a share content lock on the buffer.
7.2. Two questions, three checks
Every tuple version has a start and maybe an end:
Start: the transaction that inserted it, stored in
xmin.End: the transaction that deleted it, stored in
xmax. An UPDATE counts as a delete of the old version.
The function checks both, in order, from my point of view (my transaction and my snapshot):
Step | Question | Checks | Answer → result |
|---|---|---|---|
1 | Has the insert happened, as far as I can see? |
| No → return false (invisible), stop here. Yes → go to step 2 |
2 | Has the delete happened, as far as I can see? |
| Yes → return false (invisible). No → return true (visible) |
In one line: visible = the insert has happened for me AND the delete has not happened for me.
Why "for me"? The same tuple can give different answers to different readers. If the inserting transaction is still running, or committed after my snapshot was taken, then the insert has not happened for me, even though it has for that transaction. In the two-session example above, v1 is visible to A because its insert (XID 100) had happened for A, but its delete (XID 101, in A's xip) had not.
For each XID the same three things are asked, always in this order:
Is it me?
TransactionIdIsCurrentTransactionId()→ decide by command ID, not commit status.Is it in progress per my snapshot?
XidInMVCCSnapshot()→ treat the action as not having happened.What does pg_xact say?
TransactionIdDidCommit()→ committed or aborted; cache as a hint bit.
Hint bits cache only answer 3. Answer 2 depends on who is asking, so it is never cached.
7.3. The whole flow
flowchart TD
A[HeapTupleSatisfiesMVCC] --> B{HEAP_XMIN_COMMITTED set?}
B -- no --> C{HEAP_XMIN_INVALID?}
C -- yes --> INV[invisible]
C -- no --> D{xmin is my xact?}
D -- yes --> E{cmin >= curcid?}
E -- yes --> INV
E -- no --> OWN[own-insert sub-ladder]
D -- no --> F{XidInMVCCSnapshot xmin?}
F -- yes --> INV
F -- no --> G{TransactionIdDidCommit xmin?}
G -- no --> H[set HEAP_XMIN_INVALID] --> INV
G -- yes --> I[set HEAP_XMIN_COMMITTED] --> X
B -- yes --> J{not frozen AND xmin in snapshot?}
J -- yes --> INV
J -- no --> X{HEAP_XMAX_INVALID or lock-only?}
X -- yes --> VIS[visible]
X -- no --> K{xmax is my xact?}
K -- yes --> L{cmax >= curcid?}
L -- yes --> VIS
L -- no --> INV
K -- no --> M{XidInMVCCSnapshot xmax?}
M -- yes --> VIS
M -- no --> N{TransactionIdDidCommit xmax?}
N -- no --> O[set HEAP_XMAX_INVALID] --> VIS
N -- yes --> P[set HEAP_XMAX_COMMITTED] --> INV
7.4. xmin half, no HEAP_XMIN_COMMITTED hint yet
When no xmin hint is set, and the tuple is neither known-invalid (HEAP_XMIN_INVALID) nor inserted by me, the function takes this path:
/* heapam_visibility.c, HeapTupleSatisfiesMVCC(): the common xmin path */
else if (XidInMVCCSnapshot(HeapTupleHeaderGetRawXmin(tuple), snapshot))
return false; /* xmin still running as far as my snapshot is concerned */
else if (TransactionIdDidCommit(HeapTupleHeaderGetRawXmin(tuple)))
SetHintBits(tuple, buffer, HEAP_XMIN_COMMITTED, /* committed: pg_xact is consulted here */
HeapTupleHeaderGetRawXmin(tuple));
else
{
/* it must have aborted or crashed */
SetHintBits(tuple, buffer, HEAP_XMIN_INVALID,
InvalidTransactionId);
return false;
}
7.4.1. Own-insert sub-ladder
My transaction inserted the tuple, so nobody else can have deleted it. Its visibility depends on the command IDs in t_cid: cmin (which command in this transaction inserted the row) and cmax (which command in this transaction deleted it):
flowchart TD
A[xmin is my xact] --> B{cmin >= curcid?}
B -- yes --> INV[invisible]
B -- no --> C{HEAP_XMAX_INVALID or lock-only?}
C -- yes --> VIS[visible]
C -- no --> D{xmax is multi?}
D -- yes --> E{updater is my xact?}
E -- no --> VIS
E -- yes --> G
D -- no --> F{xmax is my xact?}
F -- no --> H[set HEAP_XMAX_INVALID] --> VIS
F -- yes --> G{cmax >= curcid?}
G -- yes --> VIS
G -- no --> INV
7.5. xmax half
By here the insert is committed and visible to me.
Test | Result |
|---|---|
| true - never deleted, or deleter aborted |
| true - a row lock, not a delete |
| find the real updater with |
no committed hint, xmax is mine |
|
no committed hint, | true |
no committed hint, | set |
no committed hint, deleter committed | set |
committed hint set, | true - "treat as still in progress" |
otherwise | false |
7.6. Worked example: my own transaction
Inside one transaction, every command gets a command ID (CID). A scan sees my own change only if an earlier command made it: CID < curcid, where curcid is the CID of the command doing the scan.
BEGIN; -- XID 200
INSERT INTO t VALUES (1); -- command 0 → cmin = 0
DELETE FROM t WHERE id = 1; -- command 1 → cmax = 1
SELECT * FROM t; -- command 2 → curcid = 2
After both commands the tuple is: xmin = 200, xmax = 200, cmin = 0 and cmax = 1 packed into t_cid as a combo CID. Not committed, so no xmin hint. Each bullet asks: what does a scan running as command curcid decide about this tuple?
curcid = 2: the SELECT, after the delete. xmin is mine →cmin >= curcid→0 >= 2false, so the insert happened for me → xmax valid, not lock-only, not multi, and mine →cmax >= curcid→1 >= 2false, so the delete happened too → invisible. The row was inserted and then deleted by earlier commands.curcid = 1: a scan that is part of the DELETE itself. Same path:0 >= 1false, so the insert happened →1 >= 1true, so the delete has not happened for this scan → visible. A command's own delete does not hide the row from that same command ("deleted after scan started").curcid = 0: a scan that is part of the INSERT itself, e.g.INSERT INTO t SELECT * FROM t.0 >= 0true → invisible; the function returns before looking at xmax. A command never sees its own inserts; otherwiseINSERT … SELECTfrom the same table would keep reading its own output and never finish ("inserted after scan started").Any other session (before 200 commits): XID 200 is in its snapshot's
xip[]→ invisible. Command IDs only matter inside the transaction that made the change.
8. Gotchas / edge cases
8.1. Order of checks
Snapshot before pg_xact, always. Commit is recorded in pg_xact before the XID leaves the proc array, so there is a window where both say "true"; checking pg_xact first could show a commit the snapshot still treats as running.
"Is it me" before the snapshot.
GetSnapshotData()skipsmypgxactoff, soXidInMVCCSnapshot()never reports my own XIDs as running. That is whyTransactionIdIsCurrentTransactionId()is tested first.Own-transaction visibility uses command IDs.
cmin >= curcid→ "inserted after scan started" → invisible.cmax >= curcid→ "deleted after scan started" → still visible.
8.2. Hint bits
A committed hint is not a final answer. Even with the committed hint bit set, the snapshot is still consulted. Only
HEAP_XMIN_INVALID,HEAP_XMAX_INVALID, lock-only and frozen short-circuit without a snapshot check.Hint bits make reads write.
SetHintBits()dirties the buffer. It refuses to set a committed hint when the buffer is permanent, the commit LSN still needs flushing, and the page LSN is older than the commit LSN (asynchronous commit); the next visitor repeats the pg_xact lookup. Abort hints can always be set.
8.3. What xmax really means
xmax is not always a deleter.
HEAP_XMAX_LOCK_ONLYmeans row lock only → tuple visible. WithHEAP_XMAX_IS_MULTI,HeapTupleGetUpdateXid()finds the real updater.Aborts need no undo, and are never looked up. Not me, not in snapshot, not committed → "aborted or crashed", decided "through process of elimination", not
TransactionIdDidAbort(header comment ofheapam_visibility.c). The tuple getsHEAP_XMIN_INVALID/HEAP_XMAX_INVALID.
8.4. System-wide effects
XID wraparound.
src/backend/access/transam/transam.c→TransactionIdPrecedes()compares normal XIDs with(int32)(id1 - id2) < 0, i.e. modulo 2^32.HEAP_XMIN_FROZENskips the snapshot check entirely.Subtransaction overflow. If any backend's subxid cache overflowed,
suboverflowedis set andXidInMVCCSnapshot()callsSubTransGetTopmostTransaction()before searchingxip[].Hot standby. All running XIDs go into
subxip[]andxip[]stays empty, because recovery does not know which XIDs are top-level (comment inGetSnapshotData()).MyProc->xminpins old versions. Set byGetSnapshotData(); a long-lived snapshot holds back cleanup.Logical decoding uses
HeapTupleSatisfiesHistoricMVCC(), which sets no hint bits and is only usable on catalog tuples.
Conclusion
MVCC in PostgreSQL comes down to two stamps on every tuple (xmin, xmax) and one snapshot per reader. HeapTupleSatisfiesMVCC() applies the same three checks - is it me, is it running for me, did it commit - first to the insert, then to the delete, and caches only the answer that doesn't depend on who is asking.
Enjoyed this? Get the next post by email.
~2 a month · no spamBy subscribing you agree to the privacy policy.