Transactions and MVCC
Commit log, hint bits and subtransactions
Whether a transaction committed is recorded in the commit log, pg_xact, using two bits per transaction. Readers copy that answer into the rows as hint bits, and savepoints add a second map, pg_subtrans, that can become a bottleneck.
In plain words
A row only says which transaction created it (its xmin). Whether that transaction committed or rolled back is written somewhere else: the commit log, stored in the pg_xact directory. It is tiny: two bits per transaction, so one 8 kB page covers 32,768 transactions.
Looking up the commit log for every row would be slow, so the first reader who finds out writes the answer into the row header as a hint bit. That is a change to the page, which is why a plain SELECT right after a big load can make pages dirty, cause disk writes and, with data checksums on (the default for new clusters since PostgreSQL 18), even generate WAL.
Savepoints (and PL/pgSQL EXCEPTION blocks) create subtransactions, which get their own IDs once they write. Each session remembers up to 64 of them in shared memory. Use more in one transaction, and every other session has to look them up in pg_subtrans instead, which can slow down a busy server dramatically.
For DBAs and operators
pg_xact
Status values: 00 in progress, 01 committed, 10 aborted, 11 sub-committed (a short intermediate state, used only while a commit whose subtransactions lie on other pg_xact pages is being recorded; a finished subtransaction otherwise stays 00 until its top-level transaction ends). The file is accessed through an SLRU cache in shared memory sized by transaction_buffers (PostgreSQL 17+, default 0 = auto, scaled from shared_buffers; wait events LWLock:XactSLRU, XactBuffer). Segments are 256 kB (32 pages, about 1 million transactions) named 0000, 0001, … and are truncated after datfrozenxid advances, see wraparound. Commit status is written to WAL as the commit record; pg_xact pages themselves are flushed at checkpoints. See SLRU caches and pg_stat_slru.
Hint bits
Infomask bits HEAP_XMIN_COMMITTED, HEAP_XMIN_INVALID, HEAP_XMAX_COMMITTED, HEAP_XMAX_INVALID cache the commit-log answer. They are set by any reader, even on a standby, and only once the transaction is known to be finished (and, for commits, its commit record flushed). Setting them marks the buffer dirty but is not WAL-logged by itself, unless data_checksums or wal_log_hints is on: then the first such change after a checkpoint writes a full-page image, because a torn page would otherwise fail its checksum. PostgreSQL 18 initdb enables checksums by default (--no-data-checksums to opt out). Measured on PostgreSQL 18.6 with checksums on, 200,000 freshly loaded rows, after a CHECKPOINT:
EXPLAIN (ANALYZE, BUFFERS, WAL) SELECT count(*) FROM hb;
Buffers: shared hit=885 dirtied=885
WAL: records=885 fpi=885 bytes=7264605 buffers full=380
-- the same query again:
Buffers: shared hit=885So the first read of new data costs a page write (and here 7 MB of WAL); a VACUUM after bulk loads sets the hint bits and the visibility map in one go.
Subtransactions
A subtransaction gets its own XID only when it writes. Its parent is recorded in pg_subtrans (another SLRU, subtransaction_buffers, PostgreSQL 17+). Each backend's PGPROC entry caches up to 64 subtransaction XIDs, so snapshot code can check "is this XID running?" from shared memory alone. With a 65th, the cache is marked overflowed and every snapshot taken by any session while that transaction is open must map unknown XIDs to their top-level parent via pg_subtrans. On busy systems this shows as many sessions waiting on LWLock:SubtransSLRU / SubtransBuffer; standbys are affected in the same way.
-- PostgreSQL 16+: per-backend subxact count and overflow flag
SELECT a.pid, s.subxact_count, s.subxact_overflowed, left(a.query, 50)
FROM pg_stat_get_backend_idset() b
JOIN pg_stat_activity a ON a.pid = pg_stat_get_backend_pid(b),
pg_stat_get_backend_subxact(b) s
WHERE s.subxact_count > 0 OR s.subxact_overflowed;Checked on 18.6: 64 savepoints with writes show 64 | f, 65 show 64 | t. Common sources: ORMs or drivers that wrap every statement in a savepoint (for example "autosave" settings), PL/pgSQL loops with an EXCEPTION block, and long batch transactions. Avoid them in hot paths, or keep such transactions short. Very long transactions with subxacts also make pg_subtrans lookups go to disk.
Multixacts (shared row locks) have their own pair of SLRUs, pg_multixact/offsets and members, with the same kind of contention and wraparound concerns.
For developers: inside the source
Where it lives
clog.c: pg_xact on top of the SLRU code inslru.c.CLOG_BITS_PER_XACTis 2,CLOG_XACTS_PER_PAGE=BLCKSZ× 4.transam.c:TransactionIdDidCommit(),TransactionIdDidAbort()and the*Treesetters;TransactionLogFetch()keeps a one-entry cache (cachedFetchXid).subtrans.c: pg_subtrans (SubTransSetParent(),SubTransGetTopmostTransaction()).heapam_visibility.c: hint bits,MarkBufferDirtyHint()andXLogSaveBufferForHint()inxloginsert.c.README: sections "pg_xact and pg_subtrans", "Writing Hints" and "Asynchronous Commit".
Writing the status
RecordTransactionCommit() inserts and flushes the commit record, then calls TransactionIdCommitTree() (or TransactionIdAsyncCommitTree() with the commit LSN) → TransactionIdSetTreeStatus(). Subtransactions on other pages are first set to TRANSACTION_STATUS_SUB_COMMITTED, then the parent, then the children. Under contention, TransactionIdSetPageStatus() uses a group update: if the SLRU bank lock (SimpleLruGetBankLock()) is busy and the transaction has at most THRESHOLD_SUBTRANS_CLOG_OPT (5) subxacts on one page, TransactionGroupUpdateXidStatus() lets one leader set the bits for many backends. For async commits, clog keeps group LSNs (CLOG_XACTS_PER_LSN_GROUP = 32) so TransactionIdGetStatus() can return the LSN that must be flushed first.
Reading it, and hint bits
HeapTupleSatisfiesMVCC() first checks the snapshot with XidInMVCCSnapshot(), then TransactionIdDidCommit(), then calls SetHintBits(). For a commit whose record is not yet flushed (asynchronous commit) it skips the hint unless the page LSN already covers it. MarkBufferDirtyHint() writes a full-page image through XLogSaveBufferForHint() when XLogHintBitIsNeeded() (checksums or wal_log_hints) and the buffer is not already dirty; the image is only taken if the page has not been logged since the last checkpoint. During recovery, in that case, it sets the bit but leaves the buffer clean.
Subtransactions
PGPROC.subxids caches up to PGPROC_MAX_CACHED_SUBXIDS (64); PGPROC.subxidStatus (XidCacheStatus: count, overflowed) is mirrored in ProcGlobal->subxidStates. GetSnapshotData() sets suboverflowed when any backend overflowed, and XidInMVCCSnapshot() then maps XIDs with SubTransGetTopmostTransaction().
Observe and test
pageinspect: heap_page_items() plus heap_tuple_infomask_flags() show hint bits before and after a read; pg_stat_slru rows transaction and subtransaction count SLRU hits and reads. src/test/modules/test_slru exercises the SLRU layer, and 012_subtransactions.pl covers subtransactions on a standby.
Parameters that shape this
transaction_buffers subtransaction_buffers wal_log_hints data_checksums full_page_writes
Related topics
MVCC: row versions Snapshots and visibility SLRU caches Full-page writes XID wraparound and freezing Write-ahead log (WAL) Locking and deadlocks