PostgreSQL transaction IDs are finite. A normal transaction ID occupies 32 bits, so the numeric counter eventually wraps and reuses values. MVCC visibility cannot treat those values as an ever-growing integer sequence. PostgreSQL instead compares normal transaction IDs in a circular space, where an ID can only remain safely classifiable as old for a bounded span.
Tuple freezing removes that age dependency for row versions whose creating transactions are far enough in the past. VACUUM records the tuple as frozen, allowing PostgreSQL to treat its insertion as visible to every normal transaction without relying on the original transaction ID’s position in the circular XID space.
Freezing is part of MVCC maintenance
A heap tuple carries transaction metadata used to decide whether a transaction may see that row version. For an ordinary committed insertion, the inserting XID participates in that decision. This works while the XID remains inside an unambiguous age range.
The wraparound constraint comes from modulo arithmetic. With roughly four billion normal XID values in the cycle, PostgreSQL interprets about half of that space as older than a given normal XID and the other half as newer. A tuple left dependent on one normal XID indefinitely would eventually cross that interpretation boundary as new transactions consume IDs.
Freezing changes the tuple’s visibility representation before that can happen. Once a tuple is frozen, its insertion is treated as being in the past for all normal transactions. The row still follows ordinary MVCC rules for later updates and deletion; freezing does not make the logical row immutable.
VACUUM has two distinct maintenance pressures
Dead tuple cleanup is the most visible effect of VACUUM, but it is not the only reason a table needs vacuum activity. A table with little or no churn can still contain old transaction metadata that must eventually be frozen.
That distinction affects page selection. A routine vacuum can skip pages that have no cleanup work, using information from the visibility map. Skipping them reduces unnecessary heap scanning, but some skipped pages can still contain tuples that have not reached frozen state.
As transaction age advances, PostgreSQL eventually performs a more aggressive vacuum. That scan visits pages that may contain unfrozen XIDs even when ordinary dead-tuple cleanup would not require them. The objective is correctness across continued XID consumption, not space reclamation.
This is also the basis for anti-wraparound autovacuum. It is possible for a table with almost no updates or deletes to receive such a vacuum because tuple age, rather than dead-row count, has reached the relevant threshold.
The visibility map tracks frozen pages separately
Each PostgreSQL heap relation has a visibility map with two bits per heap page. One bit marks a page all-visible. The other marks it all-frozen.
An all-visible page contains only tuples known to be visible to every current transaction. That state supports index-only scans because an executor can avoid a heap visibility check for matching entries on such a page.
All-frozen is a stronger property. It states that every tuple on the page has been frozen. An aggressive anti-wraparound vacuum can skip pages already marked both all-visible and all-frozen because no transaction-age work remains there.
Data modification clears visibility-map state as needed. The map is conservative: a set bit asserts a condition PostgreSQL knows to be true, while an unset bit does not by itself prove the opposite condition for every tuple on the page.
Table age exposes the maintenance horizon
PostgreSQL records a frozen-XID horizon for each table in pg_class.relfrozenxid. Its age can be inspected with age():
SELECT
n.nspname AS schema_name,
c.relname AS table_name,
age(c.relfrozenxid) AS xid_age
FROM pg_class AS c
JOIN pg_namespace AS n
ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'm')
ORDER BY xid_age DESC;The value is not a count of unfrozen tuples. It represents the age of the table-level freezing horizon. A large value therefore signals proximity to transaction-age maintenance thresholds, but it does not describe table bloat or the number of dead rows.
Database-level age also matters because every database must remain within the safe XID horizon. Operational monitoring commonly considers age(datfrozenxid) from pg_database alongside table-level values so old relations can be located before wraparound protection becomes urgent.
Aggressive vacuum is not VACUUM FULL
The term aggressive vacuum describes page-scanning behavior used for freezing old transaction metadata. It does not mean VACUUM FULL.
VACUUM FULL rewrites a table into a new physical copy to reclaim space and requires a stronger lock. An aggressive ordinary vacuum can scan broadly for old XIDs without rewriting the table. Conflating the two obscures the actual maintenance goal: anti-wraparound work is primarily about preserving MVCC visibility semantics as transaction IDs cycle.
VACUUM FREEZE can request aggressive freezing sooner by using zero-equivalent freeze age settings for that command. Routine installations generally rely on normal vacuum and autovacuum policies instead of issuing VACUUM FREEZE continuously.
Long-lived data still needs periodic attention
A mostly static table may look maintenance-free from the perspective of dead tuples, yet its transaction metadata still ages as transactions are created elsewhere in the cluster. Freezing is what disconnects sufficiently old tuple visibility from that moving XID counter.
This makes vacuum policy a correctness concern as well as a storage concern. Dead-row cleanup, visibility-map maintenance, and tuple freezing share the same subsystem, but they respond to different conditions. Keeping those conditions distinct makes table age signals easier to interpret and makes anti-wraparound vacuum activity less surprising.