Skip to content

PostgreSQL XID Wraparound and Freeze — Transaction ID Allocation, Limit Enforcement, and Tuple Freezing

Contents:

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:

  1. 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 value FrozenTransactionId (XID 2) in the tuple header, or set the HEAP_XMIN_FROZEN hint bit (a combination of HEAP_XMIN_COMMITTED | HEAP_XMIN_INVALID) in t_infomask. Either form causes the visibility check to short-circuit to “always visible.”

  2. relfrozenxid advancement. After freezing all tuples on enough pages, vacuum updates pg_class.relfrozenxid to the lowest unfrozen XID still present in the table. The cluster-wide minimum pg_database.datfrozenxid then 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.

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:

  1. 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.
  2. 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).

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 conceptPostgreSQL entity
32-bit modular XID counterTransactionId (uint32); comparator: TransactionIdPrecedes
XID freeze sentinelFrozenTransactionId (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 ladderxidVacLimit / xidWarnLimit / xidStopLimit / xidWrapLimit in TransamVariablesData
Freeze horizon (tuples older than this must be frozen)FreezeLimit in VacuumCutoffs
Per-tuple freeze planHeapTupleFreeze struct
Page-level freeze decisionHeapPageFreeze.freeze_required

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.h
typedef 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.c
void
SetTransactionIdLimit(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:

ThresholdDefault distance from wrapAction
xidVacLimitautovacuum_freeze_max_age before wrap (default 200M)Force autovacuum signal every 64K transactions
xidWarnLimit40M before wrapLog WARNING with database name and remaining budget
xidStopLimit3M before wrapRefuse new transaction XIDs with ERROR
xidWrapLimit0 (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.c
FullTransactionId
GetNewTransactionId(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:

  1. ExtendCLOG before FullTransactionIdAdvance: if CLOG extension fails, the counter is not advanced and the next caller retries. This prevents a XID from existing without a CLOG slot.
  2. MyProc->xid written before LWLockRelease: the procarray sees the new XID as active before any other backend can take a snapshot, which is essential for OldestXmin correctness.
  3. The limit check is done inside XidGenLock but the ereport calls release the lock briefly to avoid holding it during get_database_name lookups.

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.c
bool
vacuum_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.h
typedef 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.c
bool
heap_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.c
void
heap_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 void
heap_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.c
void
vac_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.

  • GetNewTransactionIdsrc/backend/access/transam/varsup.c — allocates the next XID; checks limit ladder; extends CLOG/subtrans/commit_ts before advancing nextXid; publishes into ProcArray.
  • ReadNextFullTransactionIdvarsup.c — reads nextXid without allocating (used by snapshot logic and monitoring).
  • SetTransactionIdLimitvarsup.c — recalculates and stores all four limits from oldest_datfrozenxid; called after datfrozenxid advances.
  • AdvanceNextFullTransactionIdPastXidvarsup.c — used during WAL recovery to fast-forward nextXid.
  • AdvanceOldestClogXidvarsup.c — advances oldestClogXid under XactTruncationLock; gates CLOG truncation.
  • TransamVariablesDatasrc/include/access/transam.h — the shared struct; fields under three different LWLocks.
  • vacuum_get_cutoffssrc/backend/commands/vacuum.c — derives OldestXmin, FreezeLimit, MultiXactCutoff, and the aggressive-mode threshold from VacuumParams and the procarray.
  • vacuum_xid_failsafe_checkvacuum.c — tests whether relfrozenxid distance exceeds vacuum_failsafe_age; returns true if failsafe should engage.
  • vac_update_relstatsvacuum.c — advances pg_class.relfrozenxid and pg_class.relminmxid at end of VACUUM.
  • vac_update_datfrozenxidvacuum.c — scans all relations to derive new pg_database.datfrozenxid, then calls SetTransactionIdLimit.
  • vac_truncate_clogvacuum.c — truncates CLOG pages older than oldestClogXid.

Tuple freeze planning and execution (heapam.c)

Section titled “Tuple freeze planning and execution (heapam.c)”
  • heap_prepare_freeze_tuplesrc/backend/access/heap/heapam.c — inspects xmin / xmax / xvac against cutoffs; builds HeapTupleFreeze plan; updates HeapPageFreeze trackers.
  • heap_freeze_execute_preparedheapam.c — applies all plans for a page; calls heap_execute_freeze_tuple per tuple; writes XLOG_HEAP2_FREEZE_PAGE WAL record.
  • heap_execute_freeze_tuplesrc/include/access/heapam.h (inline) — stamps new t_infomask / t_infomask2 / xmax into tuple header.
  • heap_tuple_should_freezeheapam.c — lightweight check whether a tuple would require a freeze plan; used to skip the full heap_prepare_freeze_tuple when the page is already known to need freezing for another reason.
  • FreezeMultiXactIdheapam.c — resolves MultiXact xmax values during freezing; may collapse a multi to a single XID or InvalidTransactionId.
  • HeapTupleFreezesrc/include/access/heapam.h — per-tuple freeze plan (xmax, t_infomask, frzflags, checkflags, offset).
  • HeapPageFreezeheapam.h — page-level freeze state: freeze_required flag plus FreezePageRelfrozenXid / FreezePageRelminMxid trackers.
  • FrozenTransactionIdtransam.h — XID 2, the legacy freeze sentinel.
  • HEAP_XMIN_FROZENsrc/include/access/htup_details.h — bitmask (HEAP_XMIN_COMMITTED | HEAP_XMIN_INVALID) in t_infomask; causes visibility checks to return “always visible.”
  • HEAP_FREEZE_CHECK_XMIN_COMMITTEDheapam.h — flag in checkflags asking heap_freeze_execute_prepared to 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)”
SymbolFileLine
TransamVariablesDatasrc/include/access/transam.h209
FrozenTransactionIdsrc/include/access/transam.h33
FirstNormalTransactionIdsrc/include/access/transam.h34
NormalTransactionIdPrecedessrc/include/access/transam.h147
GetNewTransactionIdsrc/backend/access/transam/varsup.c68
SetTransactionIdLimitsrc/backend/access/transam/varsup.c372
AdvanceOldestClogXidsrc/backend/access/transam/varsup.c352
vacuum_get_cutoffssrc/backend/commands/vacuum.c1116
vacuum_xid_failsafe_checksrc/backend/commands/vacuum.c1284
vac_update_relstatssrc/backend/commands/vacuum.c1442
HEAP_XMIN_FROZENsrc/include/access/htup_details.h206
HeapTupleFreezesrc/include/access/heapam.h139
HeapPageFreezesrc/include/access/heapam.h157
heap_prepare_freeze_tuplesrc/backend/access/heap/heapam.c7063
heap_freeze_execute_preparedsrc/backend/access/heap/heapam.c7389
heap_tuple_should_freezesrc/backend/access/heap/heapam.c7874
FreezeMultiXactIdsrc/backend/access/heap/heapam.c6713
  • HEAP_XMIN_FROZEN is HEAP_XMIN_COMMITTED | HEAP_XMIN_INVALID. Confirmed at htup_details.h line 206: #define HEAP_XMIN_FROZEN (HEAP_XMIN_COMMITTED|HEAP_XMIN_INVALID). The HeapTupleHeaderXminFrozen inline 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_FROZEN is the current mechanism. Older PostgreSQL versions stored XID 2 literally in xmin. Current code (confirmed in heap_prepare_freeze_tuple at line 7063) uses HEAP_XMIN_FROZEN infomask 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 via pg_upgrade.

  • SetTransactionIdLimit is the sole writer of the four thresholds. Only one call site in varsup.c writes xidVacLimit, xidWarnLimit, xidStopLimit, xidWrapLimit to TransamVariables. SetMultiXactIdLimit (for MultiXact) is a separate function with the same pattern.

  • The xidStopLimit is 3,000,000 transactions before the wrap point. Confirmed in SetTransactionIdLimit at 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.

  • xidWarnLimit is 40,000,000 transactions before the wrap point. Confirmed at line ~400: xidWarnLimit = xidWrapLimit - 40000000;. The source comment explicitly states this is not configurable.

  • ExtendCLOG is called inside XidGenLock before nextXid advances. Confirmed at lines 204–214 of varsup.c. The ordering is intentional: if ExtendCLOG fails, the transaction gets an ERROR without having consumed a XID slot. The XID is never “lost” this way.

  • heap_prepare_freeze_tuple detects corruption if xmin < relfrozenxid. At line ~7087, if a normal xmin precedes cutoffs->relfrozenxid, the function calls ereport(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_prepared is called once per page, not once per tuple. A single log_heap_freeze call at the end of heap_freeze_execute_prepared emits one XLOG_HEAP2_FREEZE_PAGE WAL record for the entire page, regardless of how many tuples were frozen. This batches WAL I/O.

  1. HeapPageFreeze.FreezePageRelfrozenXid vs. the “no-freeze” tracker. HeapPageFreeze has two sets of NewRelfrozenXid trackers: 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 in heapam.h lines 157–230 and trace heap_prepare_freeze_tuple’s conditional updates to both trackers.

  2. vacuum_freeze_min_age clamping to autovacuum_freeze_max_age / 2. The code at vacuum_get_cutoffs line ~1200 clamps freeze_min_age to autovacuum_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 -p on that clamping line in vacuum.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/xmax are 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-bit FullTransactionId used internally for WAL is a stepping stone, but widening the on-disk header is a larger project. See postgres-xact.md for the FullTransactionId epoch-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 OldestXmin fixed, preventing relfrozenxid from advancing. This also blocks the four-threshold ladder from moving, bringing the database closer to xidStopLimit. The monitoring and mitigation strategies (idle_in_transaction_session_timeout, old_snapshot_threshold, pg_terminate_backend) are covered in postgres-mvcc-snapshots.md.

  • MultiXact wraparound. MultiXactId is a parallel 32-bit counter used when multiple transactions share a tuple’s xmax lock. It has its own limit ladder (SetMultiXactIdLimit, parallel to SetTransactionIdLimit) and its own freeze cutoff (MultiXactCutoff in VacuumCutoffs). FreezeMultiXactId in heapam.c handles MultiXact resolution during tuple freezing. Documented in postgres-multixact.md (planned).

  • Evolution of the freeze mechanism. PG9.4 introduced the HEAP_XMIN_FROZEN infomask approach (replacing literal XID-2 writes). PG16 added the age(relfrozenxid) monitoring function and an urgency model improvement. PG17 reworked the dead-items store from LVDeadItems to TidStore. The full evolution arc belongs in postgres-evolution-vacuum-visibility.md (planned; see coverage map).

None (synthesized directly from the PostgreSQL source tree at REL_18_STABLE, commit 273fe94).

  • src/backend/access/transam/varsup.c — XID allocation, limit ladder computation, CLOG extension ordering.
  • src/include/access/transam.hTransamVariablesData, XID constants, NormalTransactionIdPrecedes.
  • src/backend/access/heap/heapam.cheap_prepare_freeze_tuple, heap_freeze_execute_prepared, FreezeMultiXactId, heap_tuple_should_freeze.
  • src/include/access/heapam.hHeapTupleFreeze, HeapPageFreeze, heap_execute_freeze_tuple (inline).
  • src/include/access/htup_details.hHEAP_XMIN_FROZEN and related infomask constants.
  • src/backend/commands/vacuum.cvacuum_get_cutoffs, vac_update_relstats, vac_update_datfrozenxid, vac_truncate_clog.
  • src/include/commands/vacuum.hVacuumCutoffs, VacuumParams.
  • 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.
  • 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_infomask bit definitions, HOT chains, visibility map bit mechanics.
  • postgres-mvcc-snapshots.mdOldestXmin derivation; how long transactions block relfrozenxid advancement.
  • postgres-xact.md — transaction lifecycle; FullTransactionId epoch-plus-XID split; commit/abort paths.
  • postgres-xlog-wal.mdXLOG_HEAP2_FREEZE_PAGE WAL record structure; ARIES steal/no-force contract.
  • postgres-multixact.md (planned) — MultiXactId wraparound and SetMultiXactIdLimit.
  • 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.