The idea: a 32-bit clock that wraps around

Every PostgreSQL transaction that writes gets a transaction ID (XID). Each row version stores the XID that created it in xmin. A row is visible to you only if its xmin committed before your snapshot. XIDs are 32-bit numbers, so after about 4.29 billion transactions the counter starts again at 3. A busy server can get there in weeks.

A circle of 2^32 transaction IDs. From the next XID, the half behind it (about 2.1 billion XIDs) is the past and the half ahead of it is the future. A row with xmin in the past half is visible; the same xmin would be in the future half after 2.1 billion more transactions.
From any XID, half the circle is the past and half is the future, so an old xmin that is never frozen ends up "in the future".

The canvas shrinks the circle to 64 XIDs so you can see it turn. The orange hand is the next XID. Green cells are in the past, red cells in the future. The blue dots are the rows of table orders, each placed at its xmin.

Comparing XIDs modulo 232

PostgreSQL never compares XIDs with a plain <. TransactionIdPrecedes(a, b) computes (int32)(a - b) < 0. So for any XID, the 231 (about 2.1 billion) XIDs behind it are older and the 231 ahead of it are newer. The comparison has no fixed "zero": it turns with the counter.

XIDs 0, 1 and 2 are special: InvalidTransactionId, BootstrapTransactionId and FrozenTransactionId. Normal XIDs start at 3, and after 232 − 1 the counter skips back to 3.

That is fine for new rows, but a row that stays untouched for 2.1 billion transactions has a problem. Its xmin does not change, the circle turns, and one day the xmin is more than half a circle behind. It now compares as newer than every running transaction. The row would be treated as not yet committed and would vanish from every query (Demo: no freezing: rows vanish (what if)).

Freezing a row

The fix is to freeze old rows. Once every possible snapshot can see a row, VACUUM marks it frozen: since PostgreSQL 9.4 it sets both HEAP_XMIN_COMMITTED and HEAP_XMIN_INVALID in the tuple header (together they mean HEAP_XMIN_FROZEN) and keeps the old xmin for debugging. Older releases overwrote xmin with FrozenTransactionId (2). Either way a frozen row is older than every normal XID, forever. On the canvas a frozen row leaves the circle for the frozen shelf.

Top: a row with xmin 100 that is never frozen. After 2^31 more transactions, xmin 100 compares as newer than the next XID and the row disappears. Bottom: the same row frozen by VACUUM stays visible no matter how far the counter goes.
An unfrozen row's xmin flips into the future after 231 XIDs; a frozen row stays visible forever.

Which rows does a VACUUM freeze? Those with an xmin older than its FreezeLimit = next XID − vacuum_freeze_min_age (default 50 million). Young rows are left alone because they may still be updated or deleted soon, and freezing them would be wasted work. VACUUM FREEZE uses age 0 and freezes everything it can (Demo: VACUUM vs VACUUM FREEZE). Nothing can be frozen past the oldest running transaction's XID (OldestXmin).

relfrozenxid, datfrozenxid and age()

After a VACUUM that scans every page that is not all-frozen, PostgreSQL knows that no xmin in the table is older than some XID. It stores that in pg_class.relfrozenxid. pg_database.datfrozenxid is the oldest relfrozenxid in the database (the canvas has one table, so they are equal). age(relfrozenxid) is how many XIDs have been used since: the distance to trouble. When a VACUUM moves relfrozenxid forward, the age drops and every limit below moves with it.

The settings and the limits

From the oldest datfrozenxid the server computes four limits (SetTransactionIdLimit()). The canvas uses smaller numbers in the same order:

WhatReal valueCanvasWhat happens
vacuum_freeze_min_age50,000,0004VACUUM freezes rows older than this
autovacuum_freeze_max_age200,000,00016an anti-wraparound autovacuum is forced, even with autovacuum = off
xidWarnLimit40,000,000 XIDs left8 leftWARNING: database "app" must be vacuumed within N transactions
xidStopLimit3,000,000 XIDs left3 leftno new XIDs: every write fails
xidWrapLimitdatfrozenxid + 231 − 1+ 31where the oldest unfrozen xmin would flip
A horizontal axis of age(datfrozenxid) from 0 to 2.147 billion. Marks: 50 million vacuum_freeze_min_age, 200 million autovacuum_freeze_max_age, 1.6 billion vacuum_failsafe_age, 2.107 billion warnings start (40 million left), 2.144 billion new XIDs refused (3 million left), 2.147 billion wrap limit.
Normally autovacuum resets the age near 200 million; the warnings and the stop are far out, for when it can't.

In Demo: anti-wraparound autovacuum the age reaches 16 and the autovacuum launcher forces a VACUUM public.orders (to prevent wraparound). It freezes the old rows and relfrozenxid jumps forward. Since PostgreSQL 14 the warning starts at 40 million XIDs left and the stop at 3 million (before: 10 million and 1 million). vacuum_failsafe_age (1.6 billion) also makes a VACUUM skip index cleanup and cost-based delays to finish sooner.

What stops freezing in production

Autovacuum usually keeps up. Wraparound trouble almost always means something holds OldestXmin back, so VACUUM runs but can't freeze or move relfrozenxid:

  • a session left idle in transaction for days (Demo: idle transaction: warnings, then stop);
  • a forgotten prepared transaction (PREPARE TRANSACTION, see pg_prepared_xacts);
  • a stale replication slot, or a standby with hot_standby_feedback = on holding an old xmin;
  • a table that autovacuum keeps cancelling or can't finish (huge table, too much throttling).

The fix is the same in every case: end what holds the horizon back, then VACUUM the oldest tables (Demo: fix it: end the idle txn, VACUUM). VACUUM does not need an XID, so it still runs after the stop.

-- how close is each database / table?
SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY 2 DESC;
SELECT relname, age(relfrozenxid) FROM pg_class WHERE relkind = 'r' ORDER BY 2 DESC LIMIT 10;
-- who holds the horizon back?
SELECT pid, state, backend_xid, backend_xmin, xact_start FROM pg_stat_activity
 WHERE backend_xmin IS NOT NULL ORDER BY age(backend_xmin) DESC;

See also How PostgreSQL Runs a Query for xmin, xmax and VACUUM, MVCC and isolation levels for snapshots, pg_xact (CLOG) and hint bits for how commit status is looked up, and the free space map and visibility map for the all-frozen bit that lets VACUUM skip pages.

What the page leaves out

Real XIDs are 232, not 64, and only writing transactions get one (readers use virtual transaction IDs). The page has one table; a real database has many, and datfrozenxid is the oldest of them, across all databases including template0. It leaves out vacuum_freeze_table_age (when a VACUUM becomes aggressive), the visibility map's all-frozen bit, page-level freezing in PostgreSQL 16, the separate multixact ID counter (relminmxid, autovacuum_multixact_freeze_max_age), truncation of pg_xact behind datfrozenxid, and the 64-bit "epoch" that pg_current_xact_id() adds for display.