Transactions and MVCC
MVCC: row versions
PostgreSQL never changes a row in place. Every INSERT, UPDATE and DELETE leaves stamped row versions behind, and each transaction picks the versions it is allowed to see.
In plain words
Imagine a shared notebook where nobody ever erases anything. To change a line, you cross it out (writing your name next to the cross) and write the new line below it, again with your name. Someone who started reading before you made the change simply ignores your crossing-out and your new line. That is MVCC, multi-version concurrency control.
In PostgreSQL each row version (a tuple) carries two stamps: xmin, the transaction that created it, and xmax, the transaction that deleted or replaced it. INSERT sets xmin. DELETE sets xmax. UPDATE does both: it sets xmax on the old version and writes a new version with a new xmin.
Because old versions stay in place, a reader never waits for a writer and a writer never waits for a reader. The price is cleanup: versions that no transaction can see any more are dead tuples. They take space until VACUUM removes them. Too many of them is what people call bloat.
For DBAs and operators
What is in a tuple header
Every heap tuple starts with a 23-byte header: t_xmin, t_xmax, t_cid (command id inside the transaction), t_ctid (pointer to itself or to its newer version), and the t_infomask bits (hint bits, lock bits, "updated" flags). You can see the stamps of live rows with hidden system columns, and every version on a page with pageinspect:
SELECT ctid, xmin, xmax, * FROM accounts;
CREATE EXTENSION pageinspect;
SELECT lp, t_xmin, t_xmax, t_ctid, t_infomask::bit(16)
FROM heap_page_items(get_raw_page('accounts', 0));After one UPDATE you will see two line pointers: the old one with t_xmax set and t_ctid pointing at the new one. A non-zero xmax on a visible row is normal: it can mean an aborted delete or just a row lock (SELECT … FOR UPDATE stores the locker in xmax too, see Locking).
Who decides visibility
Stamps alone mean nothing: a reader combines them with its snapshot (which transactions were still running when it looked) and the commit log (did that transaction commit or abort?). Read-only transactions do not even get a transaction id; they use a virtual xid, so pure SELECT traffic does not consume XIDs.
Consequences you meet in support
- Bloat: an UPDATE-heavy table grows even if the row count is flat.
n_dead_tupinpg_stat_user_tablesshows dead versions waiting for vacuum. - Long transactions hold back cleanup: VACUUM can only remove a version when no snapshot anywhere could still see it. One session sitting
idle in transactionfor hours while holding a snapshot or an XID (or a replica withhot_standby_feedback, or a stale replication slot) keeps every dead tuple created since then. VACUUM VERBOSE reports them as "dead but not yet removable". - HOT updates: if no indexed column changes and the page has room, the new version stays on the same page and indexes are not touched (HOT updates).
- XIDs are finite: 32-bit stamps must eventually be frozen (XID wraparound).
-- tables with most dead versions
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 10;
-- who holds back the cleanup horizon?
SELECT pid, state, backend_xmin, age(backend_xmin) AS xmin_age,
now() - xact_start AS xact_age, left(query, 60)
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC LIMIT 5;A VACUUM log line such as tuples: 0 removed, 120000 remain, 118000 are dead but not yet removable together with removable cutoff: 1203344, which was 4502113 XIDs old when operation ended almost always points at a long-running transaction, an orphaned prepared transaction or a replication slot.
For developers: inside the source
Where it lives
htup_details.h:HeapTupleHeaderDataandHeapTupleFields(t_xmin,t_xmax, and a union oft_cid/t_xvac),t_ctid,t_infomask2,t_infomask,t_hoff; the flag bits (HEAP_XMIN_COMMITTED,HEAP_XMAX_LOCK_ONLY,HEAP_XMAX_IS_MULTI,HEAP_COMBOCID,HEAP_HOT_UPDATED,HEAP_ONLY_TUPLE…); and accessors such asHeapTupleHeaderGetRawXmax()versusHeapTupleHeaderGetUpdateXid(), which resolves a MultiXact to the updating xid.heapam.c:heap_insert()(stamps xmin/cmin inheap_prepare_insert()),heap_delete(),heap_update(),heap_lock_tuple().heapam_handler.cwires these into the table AM API;combocid.cpacks cmin and cmax into one field when a transaction deletes a row it inserted;varsup.chands out xids inGetNewTransactionId(), called lazily fromAssignTransactionId()on the first write.
What heap_update() does
heapam_tuple_update() → heap_update()
HeapDetermineColumnsInfo() which indexed columns changed?
HeapTupleSatisfiesUpdate() TM_Ok / TM_BeingModified / TM_Updated ...
TM_BeingModified: XactLockTableWait() or MultiXactIdWait(), recheck
compute_new_xmax_infomask()
RelationGetBufferForTuple() only if the new version does not fit
CheckForSerializableConflictIn()
START_CRIT_SECTION()
HeapTupleSetHotUpdated() / HeapTupleSetHeapOnly() if HOT
RelationPutHeapTuple() new version, new xmin
old: t_xmax = xid, t_ctid = new TID
MarkBufferDirty(); log_heap_update(); PageSetLSN()
END_CRIT_SECTION()The result codes (TM_Result in tableam.h) travel back to the executor, which decides between waiting, EvalPlanQual or a serialization error (see Isolation levels). The page is modified under an exclusive buffer content lock; waiting for another writer uses heavyweight locks on the tuple and on the other transaction's xid, described in README.tuplock. README.HOT covers HOT chains and heap_page_prune_opt(); the "Transaction and Subtransaction Numbering" section of access/transam/README explains lazy xid assignment.
Observe and experiment
pageinspect:heap_page_items()plusheap_tuple_infomask_flags(t_infomask, t_infomask2)decodes the flag bits by name.pg_visibilityshows the all-visible/all-frozen bits;amcheck'sverify_heapam()checks xmin/xmax/ctid consistency.- Isolation specs in
src/test/isolation/specs(for exampleeval-plan-qual.spec) script concurrent sessions step by step; seeisolation/README. The injection pointheap_update-before-pinlets a test pause insideheap_update().
Extension hooks
The table access method API (TableAmRoutine: tuple_update, tuple_delete, tuple_lock, tuple_satisfies_snapshot…) is where an extension can plug in a different row-versioning scheme; heap is just the built-in implementation.
Parameters that shape this
default_transaction_isolation vacuum_freeze_min_age idle_in_transaction_session_timeout hot_standby_feedback
Related topics
Snapshots and visibility Isolation levels Commit log, hint bits and subtransactions VACUUM HOT updates Page layout XID wraparound and freezing