PostgreSQL XID Wraparound and Freeze — Transaction ID Allocation, Limit Enforcement, and Tuple Freezing
Contents:
- Theoretical Background
- Common DBMS Design
- PostgreSQL’s Approach
- Source Walkthrough
- Source verification (as of 2026-06-05)
- Beyond PostgreSQL — Comparative Designs & Research Frontiers
- Sources
Theoretical Background
Section titled “Theoretical Background”PostgreSQL uses a 32-bit transaction identifier (XID) as the primary
versioning token embedded in every heap tuple header (xmin, xmax).
The XID space spans 2^32 values (about 4.3 billion), but PostgreSQL
treats it as modular arithmetic with a half-space comparator: a XID
b is considered “newer” than XID a if b is within 2^31 of a in
the forward direction. This makes the effective ordering window roughly
2.1 billion transactions wide.
The consequence is a hard real-time obligation called XID wraparound.
If a running database accumulates 2^31 new transactions without freezing
old row versions, a tuple whose xmin was assigned 2^31 transactions ago
now appears newer than the current XID under the modular comparator.
That tuple becomes invisible — not logically deleted, but invisible to
all snapshots, which is silent data loss. Database System Concepts
(Silberschatz, 7e, §15.6 “Multiversion Concurrency Control”) describes
MVCC version visibility as determined by snapshot-based comparisons; when
the comparator’s domain wraps, visibility breaks.
Two sub-problems must be solved together:
-
Tuple freezing. Row versions whose
xmin(inserting XID) is old enough must be re-marked so they are visible to all future transactions regardless of any XID comparison. PostgreSQL uses two mechanisms: store the sentinel valueFrozenTransactionId(XID 2) in the tuple header, or set theHEAP_XMIN_FROZENhint bit (a combination ofHEAP_XMIN_COMMITTED | HEAP_XMIN_INVALID) int_infomask. Either form causes the visibility check to short-circuit to “always visible.” -
relfrozenxid advancement. After freezing all tuples on enough pages, vacuum updates
pg_class.relfrozenxidto the lowest unfrozen XID still present in the table. The cluster-wide minimumpg_database.datfrozenxidthen allows PostgreSQL to truncate old CLOG (commit-log) pages that are no longer needed to decide visibility of any live tuple.
Both sub-problems are linked to the XID allocation path: every new
transaction must check whether XID space is running low before it
receives a new identifier. This makes wraparound prevention a live
concern in GetNewTransactionId, not just in VACUUM.
Common DBMS Design
Section titled “Common DBMS Design”Monotonic counter with periodic GC obligation
Section titled “Monotonic counter with periodic GC obligation”Most MVCC engines that use integer version counters face the same structural problem: the counter must be monotonically increasing for snapshots to make sense, but a finite counter eventually overflows. The two engineering choices are:
- Widen the counter. Use 64-bit (or wider) integers so overflow is cosmetically impossible within any realistic database lifetime. SQL Server uses 64-bit version tokens; many NewSQL engines use hybrid logical clocks.
- Freeze old versions. Keep a 32-bit counter but require a background process to periodically re-mark old tuples as “beyond-the-window,” so the effective lower bound of the comparison window advances alongside the upper bound. PostgreSQL (and Firebird, historically) chose this route.
The freeze approach creates a GC obligation: the system is only safe
as long as background maintenance keeps up with the transaction rate.
The obligation is made concrete by a set of per-table and per-database
frozen XID horizons (relfrozenxid, datfrozenxid).
Graduated warning and stop thresholds
Section titled “Graduated warning and stop thresholds”A sensible design places multiple alert levels between normal operation and the point of data loss, rather than a single hard stop. This gives operators time to react:
- A vacuum trigger level: autovacuum is forced before any user warning appears.
- A warning level: ereport messages in the server log.
- A stop level: the server stops assigning new XIDs to protect existing data.
- The actual wrap point: data corruption if reached.
The gap between the stop level and the wrap point must be wide enough for a DBA to diagnose and execute an emergency VACUUM in single-user mode.
Theory ↔ PostgreSQL mapping
Section titled “Theory ↔ PostgreSQL mapping”| Theory concept | PostgreSQL entity |
|---|---|
| 32-bit modular XID counter | TransactionId (uint32); comparator: TransactionIdPrecedes |
| XID freeze sentinel | FrozenTransactionId (XID 2); or HEAP_XMIN_FROZEN in t_infomask |
| GC obligation lower bound (per table) | pg_class.relfrozenxid |
| GC obligation lower bound (per database) | pg_database.datfrozenxid |
| Graduated warning ladder | xidVacLimit / xidWarnLimit / xidStopLimit / xidWrapLimit in TransamVariablesData |
| Freeze horizon (tuples older than this must be frozen) | FreezeLimit in VacuumCutoffs |
| Per-tuple freeze plan | HeapTupleFreeze struct |
| Page-level freeze decision | HeapPageFreeze.freeze_required |
PostgreSQL’s Approach
Section titled “PostgreSQL’s Approach”XID space and the modular comparator
Section titled “XID space and the modular comparator”A TransactionId is a uint32. Three special values are reserved below
the normal range:
// transam.h — src/include/access/transam.h#define InvalidTransactionId ((TransactionId) 0)#define BootstrapTransactionId ((TransactionId) 1)#define FrozenTransactionId ((TransactionId) 2)#define FirstNormalTransactionId ((TransactionId) 3)All arithmetic on normal XIDs must account for these specials.
TransactionIdIsNormal(xid) returns true only for XID >= 3.
The modular “precedes” relation is defined using the half-space rule:
// NormalTransactionIdPrecedes — src/include/access/transam.h#define NormalTransactionIdPrecedes(id1, id2) \ (AssertMacro(TransactionIdIsNormal(id1) && TransactionIdIsNormal(id2)), \ (int32) ((id1) - (id2)) < 0)The cast to int32 converts the unsigned subtraction into a signed
difference: id1 < id2 in the modular sense if and only if the signed
difference is negative, i.e. id1 is within 2^31 ahead of id2 in the
backward direction. This is the comparator that every visibility check,
snapshot test, and limit check uses.
TransamVariablesData: the shared XID state
Section titled “TransamVariablesData: the shared XID state”The global XID counter and all four wraparound thresholds live in
TransamVariablesData, a single shared-memory struct (one instance per
cluster):
// TransamVariablesData — src/include/access/transam.htypedef struct TransamVariablesData{ /* Protected by OidGenLock */ Oid nextOid; /* next OID to assign */ uint32 oidCount; /* OIDs available before XLOG work needed */
/* Protected by XidGenLock */ FullTransactionId nextXid; /* next XID to assign (64-bit epoch+32-bit) */
TransactionId oldestXid; /* cluster-wide minimum datfrozenxid */ TransactionId xidVacLimit; /* start forcing autovacuums here */ TransactionId xidWarnLimit; /* start logging warnings here */ TransactionId xidStopLimit; /* refuse to assign XIDs beyond here */ TransactionId xidWrapLimit; /* the point of data corruption */ Oid oldestXidDB; /* database owning oldestXid */
/* Protected by CommitTsLock */ TransactionId oldestCommitTsXid; TransactionId newestCommitTsXid;
/* Protected by ProcArrayLock */ FullTransactionId latestCompletedXid; uint64 xactCompletionCount;
/* Protected by XactTruncationLock */ TransactionId oldestClogXid; /* oldest safe CLOG lookup XID */} TransamVariablesData;The counter nextXid is a FullTransactionId — a 64-bit value
combining a 32-bit epoch and a 32-bit XID — to detect epoch rollovers
during WAL replay. At user-visible level, only the 32-bit
XidFromFullTransactionId(nextXid) matters for wraparound accounting.
Setting the limit ladder: SetTransactionIdLimit
Section titled “Setting the limit ladder: SetTransactionIdLimit”SetTransactionIdLimit is called whenever pg_database.datfrozenxid
advances (after each successful VACUUM that runs vac_update_datfrozenxid).
It recomputes all four thresholds from oldest_datfrozenxid:
// SetTransactionIdLimit — src/backend/access/transam/varsup.cvoidSetTransactionIdLimit(TransactionId oldest_datfrozenxid, Oid oldest_datoid){ /* xidWrapLimit = oldest_datfrozenxid + 2^31 (half the XID space) */ xidWrapLimit = oldest_datfrozenxid + (MaxTransactionId >> 1); if (xidWrapLimit < FirstNormalTransactionId) xidWrapLimit += FirstNormalTransactionId;
/* Stop assigning XIDs 3M before the wrap point */ xidStopLimit = xidWrapLimit - 3000000;
/* Issue warnings 40M before the wrap point */ xidWarnLimit = xidWrapLimit - 40000000;
/* Force autovacuum when oldest_datfrozenxid is autovacuum_freeze_max_age old */ xidVacLimit = oldest_datfrozenxid + autovacuum_freeze_max_age;
LWLockAcquire(XidGenLock, LW_EXCLUSIVE); TransamVariables->xidVacLimit = xidVacLimit; TransamVariables->xidWarnLimit = xidWarnLimit; TransamVariables->xidStopLimit = xidStopLimit; TransamVariables->xidWrapLimit = xidWrapLimit; TransamVariables->oldestXidDB = oldest_datoid; LWLockRelease(XidGenLock);}The four thresholds define the graduated response ladder:
| Threshold | Default distance from wrap | Action |
|---|---|---|
xidVacLimit | autovacuum_freeze_max_age before wrap (default 200M) | Force autovacuum signal every 64K transactions |
xidWarnLimit | 40M before wrap | Log WARNING with database name and remaining budget |
xidStopLimit | 3M before wrap | Refuse new transaction XIDs with ERROR |
xidWrapLimit | 0 (the actual wrap point) | Reference value for messages only |
Figure 1 — XID space and the four-threshold ladder
flowchart LR
A["oldest_datfrozenxid\n(cluster floor)"] --> B["xidVacLimit\n(force autovacuum)"]
B --> C["xidWarnLimit\n(log warning)"]
C --> D["xidStopLimit\n(refuse new XIDs)"]
D --> E["xidWrapLimit\n(data corruption)"]
E --> F["oldest_datfrozenxid\n+2^32 (wraps here)"]
style A fill:#aef,stroke:#333
style E fill:#faa,stroke:#333
Figure 1 — The XID number line from the cluster’s oldest frozen XID to the wrap point. Effective safe window is 2^31 (~2.1 billion) transactions. The four thresholds are placed at increasing distances before the wrap point.
GetNewTransactionId: allocating XIDs under XidGenLock
Section titled “GetNewTransactionId: allocating XIDs under XidGenLock”Every normal transaction that needs a XID calls GetNewTransactionId:
// GetNewTransactionId — src/backend/access/transam/varsup.cFullTransactionIdGetNewTransactionId(bool isSubXact){ LWLockAcquire(XidGenLock, LW_EXCLUSIVE);
full_xid = TransamVariables->nextXid; xid = XidFromFullTransactionId(full_xid);
/* Check the limit ladder */ if (TransactionIdFollowsOrEquals(xid, TransamVariables->xidVacLimit)) { LWLockRelease(XidGenLock); /* ... signal autovacuum launcher every 64K XIDs ... */ if (TransactionIdFollowsOrEquals(xid, xidStopLimit)) ereport(ERROR, ...); /* refuse */ else if (TransactionIdFollowsOrEquals(xid, xidWarnLimit)) ereport(WARNING, ...); LWLockAcquire(XidGenLock, LW_EXCLUSIVE); full_xid = TransamVariables->nextXid; xid = XidFromFullTransactionId(full_xid); }
/* Extend CLOG/subtrans/commit_ts pages before advancing the counter */ ExtendCLOG(xid); ExtendCommitTs(xid); ExtendSUBTRANS(xid);
/* Now advance the counter */ FullTransactionIdAdvance(&TransamVariables->nextXid);
/* Publish into ProcArray before releasing lock */ MyProc->xid = xid; ProcGlobal->xids[MyProc->pgxactoff] = xid;
LWLockRelease(XidGenLock); return full_xid;}Three ordering constraints are embedded here:
ExtendCLOGbeforeFullTransactionIdAdvance: if CLOG extension fails, the counter is not advanced and the next caller retries. This prevents a XID from existing without a CLOG slot.MyProc->xidwritten beforeLWLockRelease: the procarray sees the new XID as active before any other backend can take a snapshot, which is essential forOldestXmincorrectness.- The limit check is done inside
XidGenLockbut theereportcalls release the lock briefly to avoid holding it duringget_database_namelookups.
vacuum_get_cutoffs: deriving the freeze horizon
Section titled “vacuum_get_cutoffs: deriving the freeze horizon”Before any VACUUM run, vacuum_get_cutoffs computes the four immutable
XID horizons stored in VacuumCutoffs. The freeze horizon FreezeLimit
is the key one for this document:
// vacuum_get_cutoffs (abridged) — src/backend/commands/vacuum.cboolvacuum_get_cutoffs(Relation rel, const VacuumParams *params, struct VacuumCutoffs *cutoffs){ int freeze_min_age = params->freeze_min_age; int freeze_table_age = params->freeze_table_age;
/* Clamp freeze_min_age to at most half autovacuum_freeze_max_age */ freeze_min_age = Min(freeze_min_age, autovacuum_freeze_max_age / 2);
/* FreezeLimit = nextXID - freeze_min_age (default 50M) */ cutoffs->FreezeLimit = nextXID - freeze_min_age;
/* Aggressive vacuum threshold: table's relfrozenxid is too old */ /* freeze_table_age capped at 0.95 * autovacuum_freeze_max_age */ freeze_table_age = Min(freeze_table_age, autovacuum_freeze_max_age * 0.95); /* ... set cutoffs->OldestXmin from procarray ... */}Any tuple whose xmin is older than FreezeLimit is a candidate for
freezing. The aggressive flag in LVRelState is set when the table’s
relfrozenxid is already older than freeze_table_age transactions ago,
forcing vacuum to visit every unfrozen page rather than skipping
all-visible pages.
heap_prepare_freeze_tuple: per-tuple freeze planning
Section titled “heap_prepare_freeze_tuple: per-tuple freeze planning”heap_prepare_freeze_tuple (called by lazy_scan_prune via
heap_page_prune_and_freeze in pruneheap.c) decides for each live
tuple whether and how to freeze it. It returns a HeapTupleFreeze plan:
// HeapTupleFreeze — src/include/access/heapam.htypedef struct HeapTupleFreeze{ TransactionId xmax; /* new xmax value (or unchanged) */ uint16 t_infomask2; uint16 t_infomask; /* new infomask with freeze bits applied */ uint8 frzflags; /* XVAC replacement flags */ uint8 checkflags; /* HEAP_FREEZE_CHECK_XMIN_COMMITTED etc. */ OffsetNumber offset; /* tuple's page offset */} HeapTupleFreeze;The planning function inspects xmin and xmax (and the xvac
legacy field) against cutoffs->FreezeLimit and
cutoffs->MultiXactCutoff:
// heap_prepare_freeze_tuple (abridged) — src/backend/access/heap/heapam.cboolheap_prepare_freeze_tuple(HeapTupleHeader tuple, const struct VacuumCutoffs *cutoffs, HeapPageFreeze *pagefrz, HeapTupleFreeze *frz, bool *totally_frozen){ xid = HeapTupleHeaderGetXmin(tuple); if (TransactionIdIsNormal(xid)) { /* Error if xmin is before relfrozenxid — should have been frozen */ if (TransactionIdPrecedes(xid, cutoffs->relfrozenxid)) ereport(ERROR, ...);
/* Freeze if xmin is older than FreezeLimit */ freeze_xmin = TransactionIdPrecedes(xid, cutoffs->OldestXmin); if (freeze_xmin) frz->checkflags |= HEAP_FREEZE_CHECK_XMIN_COMMITTED; } else xmin_already_frozen = true; /* already frozen or InvalidXID */
/* ... similar logic for xmax / MultiXactId ... */
/* If page freeze is required (e.g. xvac or MultiXact cutoff), set flag */ /* pagefrz->freeze_required = true means caller MUST freeze this page */
return (freeze_xmin || replace_xvac || replace_xmax || freeze_xmax);}The HeapPageFreeze struct tracks whether the page must be frozen
(mandatory) and what the post-freeze NewRelfrozenXid / NewRelminMxid
would be, so that the vacuum’s top-level trackers can be updated
accurately.
heap_freeze_execute_prepared: applying the freeze plans
Section titled “heap_freeze_execute_prepared: applying the freeze plans”After heap_prepare_freeze_tuple has been called for every live tuple
on a page and the caller decides to freeze the page,
heap_freeze_execute_prepared applies each plan:
// heap_freeze_execute_prepared (abridged) — src/backend/access/heap/heapam.cvoidheap_freeze_execute_prepared(Relation rel, Buffer buffer, TransactionId FreezeLimit, HeapTupleFreeze *tuples, int ntuples){ for (i = 0; i < ntuples; i++) { HeapTupleFreeze *frz = tuples + i; /* ... check xmin committed if checkflags says to ... */ heap_execute_freeze_tuple(htup, frz); } /* Write a single XLOG_HEAP2_FREEZE_PAGE WAL record for the whole page */ log_heap_freeze(rel, buffer, FreezeLimit, tuples, ntuples);}heap_execute_freeze_tuple (an inline in heapam.h) stamps the new
t_infomask bits:
// heap_execute_freeze_tuple — src/include/access/heapam.h (inline)static inline voidheap_execute_freeze_tuple(HeapTupleHeader tuple, HeapTupleFreeze *frz){ HeapTupleHeaderSetXmax(tuple, frz->xmax); tuple->t_infomask2 = frz->t_infomask2; tuple->t_infomask = frz->t_infomask; if (frz->frzflags & XLH_FREEZE_KILL_XVAC) HeapTupleHeaderSetXvac(tuple, InvalidTransactionId); /* HEAP_XMIN_FROZEN = HEAP_XMIN_COMMITTED | HEAP_XMIN_INVALID */}The HEAP_XMIN_FROZEN bit combination causes every subsequent visibility
check to skip the XID comparison entirely and return “always visible.”
Figure 2 — Freeze planning and execution per page
flowchart TD
A[lazy_scan_prune] --> B[heap_page_prune_and_freeze\npruneheap.c]
B --> C{for each live tuple}
C --> D[heap_prepare_freeze_tuple\nbuilds HeapTupleFreeze plan]
D --> E{totally_frozen?}
E -- yes --> F[increment totally_frozen count]
E -- no --> G[add to freeze plan array]
C --> H{pagefrz.freeze_required\nor vacrel.aggressive?}
H -- yes --> I[heap_freeze_execute_prepared\napply all plans]
I --> J[heap_execute_freeze_tuple\nstamp t_infomask HEAP_XMIN_FROZEN]
I --> K[log_heap_freeze\nXLOG_HEAP2_FREEZE_PAGE WAL record]
H -- no --> L[discard plans\nno page freeze this cycle]
Figure 2 — Per-page freeze lifecycle. heap_prepare_freeze_tuple builds plans for all live tuples; heap_freeze_execute_prepared applies them only when the page must be frozen (mandatory freeze or aggressive mode). A single WAL record covers the entire page.
Advancing relfrozenxid and truncating CLOG
Section titled “Advancing relfrozenxid and truncating CLOG”At the end of heap_vacuum_rel, the vacuum records the lowest unfrozen
XID observed across all scanned pages in vacrel.NewRelfrozenXid.
vac_update_relstats advances pg_class.relfrozenxid to this value:
// vac_update_relstats (abridged) — src/backend/commands/vacuum.cvoidvac_update_relstats(Relation relation, ..., TransactionId frozenxid, ...){ oldfrozenxid = pgcform->relfrozenxid; /* Never move relfrozenxid backwards (unless the stored value is corrupt) */ if (TransactionIdPrecedes(oldfrozenxid, frozenxid)) pgcform->relfrozenxid = frozenxid;}After all per-relation updates, vac_update_datfrozenxid scans
pg_class.relfrozenxid across all relations to derive the new
pg_database.datfrozenxid, then calls SetTransactionIdLimit to push
the four-threshold ladder forward. Finally, vac_truncate_clog uses
TransamVariables->oldestClogXid (maintained via AdvanceOldestClogXid)
to truncate CLOG pages that are no longer needed.
Figure 3 — End-to-end wraparound prevention cycle
flowchart TD
A[VACUUM / autovacuum] --> B[vacuum_get_cutoffs\nFreezeLimit = nextXID - freeze_min_age]
B --> C[lazy_scan_heap\nvisit every unfrozen page in aggressive mode]
C --> D[heap_prepare_freeze_tuple\nfor each tuple older than FreezeLimit]
D --> E[heap_freeze_execute_prepared\nstamp HEAP_XMIN_FROZEN]
E --> F[vac_update_relstats\nadvance pg_class.relfrozenxid]
F --> G[vac_update_datfrozenxid\nadvance pg_database.datfrozenxid]
G --> H[SetTransactionIdLimit\nrecalculate xidVacLimit/Warn/Stop/WrapLimit]
H --> I[vac_truncate_clog\nfree old CLOG pages]
H --> J[GetNewTransactionId\nchecks new limits on each XID allocation]
Figure 3 — The wraparound prevention cycle: VACUUM freezes old tuples, advances relfrozenxid/datfrozenxid, which resets the four-threshold ladder so GetNewTransactionId sees updated limits.
Source Walkthrough
Section titled “Source Walkthrough”XID allocation (varsup.c)
Section titled “XID allocation (varsup.c)”GetNewTransactionId—src/backend/access/transam/varsup.c— allocates the next XID; checks limit ladder; extends CLOG/subtrans/commit_ts before advancingnextXid; publishes into ProcArray.ReadNextFullTransactionId—varsup.c— readsnextXidwithout allocating (used by snapshot logic and monitoring).SetTransactionIdLimit—varsup.c— recalculates and stores all four limits fromoldest_datfrozenxid; called afterdatfrozenxidadvances.AdvanceNextFullTransactionIdPastXid—varsup.c— used during WAL recovery to fast-forwardnextXid.AdvanceOldestClogXid—varsup.c— advancesoldestClogXidunderXactTruncationLock; gates CLOG truncation.TransamVariablesData—src/include/access/transam.h— the shared struct; fields under three different LWLocks.
Freeze horizon computation (vacuum.c)
Section titled “Freeze horizon computation (vacuum.c)”vacuum_get_cutoffs—src/backend/commands/vacuum.c— derivesOldestXmin,FreezeLimit,MultiXactCutoff, and the aggressive-mode threshold fromVacuumParamsand the procarray.vacuum_xid_failsafe_check—vacuum.c— tests whetherrelfrozenxiddistance exceedsvacuum_failsafe_age; returns true if failsafe should engage.vac_update_relstats—vacuum.c— advancespg_class.relfrozenxidandpg_class.relminmxidat end of VACUUM.vac_update_datfrozenxid—vacuum.c— scans all relations to derive newpg_database.datfrozenxid, then callsSetTransactionIdLimit.vac_truncate_clog—vacuum.c— truncates CLOG pages older thanoldestClogXid.
Tuple freeze planning and execution (heapam.c)
Section titled “Tuple freeze planning and execution (heapam.c)”heap_prepare_freeze_tuple—src/backend/access/heap/heapam.c— inspectsxmin/xmax/xvacagainst cutoffs; buildsHeapTupleFreezeplan; updatesHeapPageFreezetrackers.heap_freeze_execute_prepared—heapam.c— applies all plans for a page; callsheap_execute_freeze_tupleper tuple; writesXLOG_HEAP2_FREEZE_PAGEWAL record.heap_execute_freeze_tuple—src/include/access/heapam.h(inline) — stamps newt_infomask/t_infomask2/xmaxinto tuple header.heap_tuple_should_freeze—heapam.c— lightweight check whether a tuple would require a freeze plan; used to skip the fullheap_prepare_freeze_tuplewhen the page is already known to need freezing for another reason.FreezeMultiXactId—heapam.c— resolves MultiXact xmax values during freezing; may collapse a multi to a single XID orInvalidTransactionId.HeapTupleFreeze—src/include/access/heapam.h— per-tuple freeze plan (xmax, t_infomask, frzflags, checkflags, offset).HeapPageFreeze—heapam.h— page-level freeze state:freeze_requiredflag plusFreezePageRelfrozenXid/FreezePageRelminMxidtrackers.
Key constants and structs
Section titled “Key constants and structs”FrozenTransactionId—transam.h— XID 2, the legacy freeze sentinel.HEAP_XMIN_FROZEN—src/include/access/htup_details.h— bitmask(HEAP_XMIN_COMMITTED | HEAP_XMIN_INVALID)int_infomask; causes visibility checks to return “always visible.”HEAP_FREEZE_CHECK_XMIN_COMMITTED—heapam.h— flag incheckflagsaskingheap_freeze_execute_preparedto verify xmin was committed before stamping the frozen bit.
Position hints (as of 2026-06-05 / commit 273fe94)
Section titled “Position hints (as of 2026-06-05 / commit 273fe94)”| Symbol | File | Line |
|---|---|---|
TransamVariablesData | src/include/access/transam.h | 209 |
FrozenTransactionId | src/include/access/transam.h | 33 |
FirstNormalTransactionId | src/include/access/transam.h | 34 |
NormalTransactionIdPrecedes | src/include/access/transam.h | 147 |
GetNewTransactionId | src/backend/access/transam/varsup.c | 68 |
SetTransactionIdLimit | src/backend/access/transam/varsup.c | 372 |
AdvanceOldestClogXid | src/backend/access/transam/varsup.c | 352 |
vacuum_get_cutoffs | src/backend/commands/vacuum.c | 1116 |
vacuum_xid_failsafe_check | src/backend/commands/vacuum.c | 1284 |
vac_update_relstats | src/backend/commands/vacuum.c | 1442 |
HEAP_XMIN_FROZEN | src/include/access/htup_details.h | 206 |
HeapTupleFreeze | src/include/access/heapam.h | 139 |
HeapPageFreeze | src/include/access/heapam.h | 157 |
heap_prepare_freeze_tuple | src/backend/access/heap/heapam.c | 7063 |
heap_freeze_execute_prepared | src/backend/access/heap/heapam.c | 7389 |
heap_tuple_should_freeze | src/backend/access/heap/heapam.c | 7874 |
FreezeMultiXactId | src/backend/access/heap/heapam.c | 6713 |
Source verification (as of 2026-06-05)
Section titled “Source verification (as of 2026-06-05)”Verified facts
Section titled “Verified facts”-
HEAP_XMIN_FROZENisHEAP_XMIN_COMMITTED | HEAP_XMIN_INVALID. Confirmed athtup_details.hline 206:#define HEAP_XMIN_FROZEN (HEAP_XMIN_COMMITTED|HEAP_XMIN_INVALID). TheHeapTupleHeaderXminFrozeninline at line 357 tests for exactly this combination. It is not a single bit — it is a bit-pair. -
FrozenTransactionId(XID 2) is the legacy sentinel;HEAP_XMIN_FROZENis the current mechanism. Older PostgreSQL versions stored XID 2 literally inxmin. Current code (confirmed inheap_prepare_freeze_tupleat line 7063) usesHEAP_XMIN_FROZENinfomask bits and does not write XID 2 into the header for newly frozen tuples. XID 2 still appears in pre-PG9.4 databases brought forward viapg_upgrade. -
SetTransactionIdLimitis the sole writer of the four thresholds. Only one call site invarsup.cwritesxidVacLimit,xidWarnLimit,xidStopLimit,xidWrapLimittoTransamVariables.SetMultiXactIdLimit(for MultiXact) is a separate function with the same pattern. -
The
xidStopLimitis 3,000,000 transactions before the wrap point. Confirmed inSetTransactionIdLimitat line ~407:xidStopLimit = xidWrapLimit - 3000000;. This leaves room for single-user mode VACUUM to run. The value is a compile-time constant, not a GUC. -
xidWarnLimitis 40,000,000 transactions before the wrap point. Confirmed at line ~400:xidWarnLimit = xidWrapLimit - 40000000;. The source comment explicitly states this is not configurable. -
ExtendCLOGis called insideXidGenLockbeforenextXidadvances. Confirmed at lines 204–214 ofvarsup.c. The ordering is intentional: ifExtendCLOGfails, the transaction gets an ERROR without having consumed a XID slot. The XID is never “lost” this way. -
heap_prepare_freeze_tupledetects corruption ifxmin < relfrozenxid. At line ~7087, if a normal xmin precedescutoffs->relfrozenxid, the function callsereport(ERROR, errcode(ERRCODE_DATA_CORRUPTED), ...). This is a hard assertion against previously unfrozen tuples that should have been frozen by an earlier VACUUM. -
heap_freeze_execute_preparedis called once per page, not once per tuple. A singlelog_heap_freezecall at the end ofheap_freeze_execute_preparedemits oneXLOG_HEAP2_FREEZE_PAGEWAL record for the entire page, regardless of how many tuples were frozen. This batches WAL I/O.
Open questions
Section titled “Open questions”-
HeapPageFreeze.FreezePageRelfrozenXidvs. the “no-freeze” tracker.HeapPageFreezehas two sets ofNewRelfrozenXidtrackers: one for the “freeze” path and one for the “no freeze” path. The exact interplay — specifically when the “no-freeze” tracker ratchets back — was not fully traced. Investigation path: read the comment block inheapam.hlines 157–230 and traceheap_prepare_freeze_tuple’s conditional updates to both trackers. -
vacuum_freeze_min_ageclamping toautovacuum_freeze_max_age / 2. The code atvacuum_get_cutoffsline ~1200 clampsfreeze_min_agetoautovacuum_freeze_max_age / 2. The rationale (preventing FreezeLimit from being set so conservatively that autovacuum’s forced aggressiveness never catches up) was inferred from comments but not verified against the commit history. Investigation path:git log -pon that clamping line invacuum.c.
Beyond PostgreSQL — Comparative Designs & Research Frontiers
Section titled “Beyond PostgreSQL — Comparative Designs & Research Frontiers”-
64-bit XIDs (natural prevention). Many newer systems — SQL Server (version tokens), CockroachDB (HLC timestamps), YugabyteDB — use 64-bit or wider identifiers where overflow is computationally unreachable. The tradeoff is wider tuple headers: PostgreSQL’s 32-bit
xmin/xmaxare 4 bytes each; 64-bit would double that per header field, impacting row density. PostgreSQL’s development mailing lists have discussed widening XIDs several times; the consensus has been that the 64-bitFullTransactionIdused internally for WAL is a stepping stone, but widening the on-disk header is a larger project. Seepostgres-xact.mdfor theFullTransactionIdepoch-plus-XID split. -
InnoDB’s purge and version chains. InnoDB avoids the wraparound problem by keeping old versions in a separate undo log (rollback segments), not in the primary tablespace. The version token (6-byte trx_id) is effectively 48-bit and wraps only after 72 quadrillion transactions, making it cosmetically infinite. The tradeoff is a separate purge mechanism and potential undo segment bloat under long transactions. A comparative analysis of in-place vs. undo-log MVCC is a natural companion to
postgres-heap-am.md. -
The VACUUM obligation and long transactions. A long-running transaction holds
OldestXminfixed, preventingrelfrozenxidfrom advancing. This also blocks the four-threshold ladder from moving, bringing the database closer toxidStopLimit. The monitoring and mitigation strategies (idle_in_transaction_session_timeout,old_snapshot_threshold,pg_terminate_backend) are covered inpostgres-mvcc-snapshots.md. -
MultiXact wraparound.
MultiXactIdis a parallel 32-bit counter used when multiple transactions share a tuple’sxmaxlock. It has its own limit ladder (SetMultiXactIdLimit, parallel toSetTransactionIdLimit) and its own freeze cutoff (MultiXactCutoffinVacuumCutoffs).FreezeMultiXactIdinheapam.chandles MultiXact resolution during tuple freezing. Documented inpostgres-multixact.md(planned). -
Evolution of the freeze mechanism. PG9.4 introduced the
HEAP_XMIN_FROZENinfomask approach (replacing literal XID-2 writes). PG16 added theage(relfrozenxid)monitoring function and an urgency model improvement. PG17 reworked the dead-items store fromLVDeadItemstoTidStore. The full evolution arc belongs inpostgres-evolution-vacuum-visibility.md(planned; see coverage map).
Sources
Section titled “Sources”Raw sources consumed
Section titled “Raw sources consumed”None (synthesized directly from the PostgreSQL source tree at REL_18_STABLE, commit 273fe94).
Source code paths
Section titled “Source code paths”src/backend/access/transam/varsup.c— XID allocation, limit ladder computation, CLOG extension ordering.src/include/access/transam.h—TransamVariablesData, XID constants,NormalTransactionIdPrecedes.src/backend/access/heap/heapam.c—heap_prepare_freeze_tuple,heap_freeze_execute_prepared,FreezeMultiXactId,heap_tuple_should_freeze.src/include/access/heapam.h—HeapTupleFreeze,HeapPageFreeze,heap_execute_freeze_tuple(inline).src/include/access/htup_details.h—HEAP_XMIN_FROZENand related infomask constants.src/backend/commands/vacuum.c—vacuum_get_cutoffs,vac_update_relstats,vac_update_datfrozenxid,vac_truncate_clog.src/include/commands/vacuum.h—VacuumCutoffs,VacuumParams.
Textbook anchors
Section titled “Textbook anchors”- Database System Concepts, 7e (Silberschatz et al.), §15.6 “Multiversion Concurrency Control” — MVCC visibility framing; version chains and snapshot predicates.
- Database Internals (Petrov, 2019), ch. 5 §“MVCC Versions and Cleanup” — GC obligation and horizon advancement.
Related documents in this tree
Section titled “Related documents in this tree”postgres-vacuum.md— the three-phase VACUUM loop that drives freeze planning;LVRelState,lazy_scan_prune,lazy_scan_heap.postgres-heap-am.md— heap tuple layout,t_infomaskbit definitions, HOT chains, visibility map bit mechanics.postgres-mvcc-snapshots.md—OldestXminderivation; how long transactions blockrelfrozenxidadvancement.postgres-xact.md— transaction lifecycle;FullTransactionIdepoch-plus-XID split; commit/abort paths.postgres-xlog-wal.md—XLOG_HEAP2_FREEZE_PAGEWAL record structure; ARIES steal/no-force contract.postgres-multixact.md(planned) —MultiXactIdwraparound andSetMultiXactIdLimit.postgres-evolution-vacuum-visibility.md(planned) — historical arc of freeze mechanism changes across major releases.dbms-papers/aries.md— ARIES paper; WAL correctness underpinning the freeze WAL record.