PostgreSQL transaction IDs are 32-bit values, so the normal XID space eventually wraps. MVCC visibility still has to distinguish old row versions from transactions that have not happened yet. PostgreSQL resolves that finite-number problem by freezing sufficiently old tuple versions during vacuum processing.

Freezing is not primarily a space-reclamation feature. It is a correctness mechanism that lets long-lived rows remain valid as the transaction counter continues around its finite range.

Normal transaction IDs live on a circular timeline

A tuple header records transaction information used by MVCC. For an inserted row version, the inserting transaction ID participates in visibility checks that determine whether another transaction may see that version.

Normal XIDs are compared with wraparound-aware arithmetic. Around any normal XID, roughly half of the 32-bit normal-ID space is treated as older and half as newer. A row version can therefore keep its ordinary transaction identity only for a bounded interval.

Without another representation for old committed tuples, an insertion XID could eventually cross the comparison horizon and appear to belong to a future transaction. PostgreSQL must establish that old row versions are permanently in the past before that boundary is reached.

Frozen tuples no longer depend on ordinary XID age

VACUUM can mark a tuple as frozen once its inserting transaction is old enough that every relevant transaction can treat the tuple as committed and visible according to normal MVCC rules.

Modern PostgreSQL preserves the tuple’s original xmin value and records frozen state with tuple metadata rather than replacing xmin with the special frozen transaction ID. Visibility semantics still treat a frozen insertion as older than every normal transaction.

That distinction matters when inspecting tuple metadata. Seeing an old numeric xmin does not by itself mean the row was missed by freezing. PostgreSQL versions since 9.4 can retain that original value after the tuple has become frozen.

Freezing also does not make a row universally visible in every sense. A tuple may carry deletion or update state that affects its visibility. The frozen insertion state only removes the age-sensitive dependency on its original inserting XID.

relfrozenxid records a table-level boundary

PostgreSQL tracks freezing progress in pg_class.relfrozenxid. The value represents a lower boundary for ordinary transaction IDs that can still matter in the relation. After vacuum advances this value, XIDs older than the boundary no longer need to be retained as unfrozen insertion history in that table.

The age can be inspected with SQL:

SELECT
    n.nspname,
    c.relname,
    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;

A large age(relfrozenxid) is a signal about the oldest transaction horizon retained by a relation. It is not a count of dead tuples, table updates, or transactions that touched that table.

At database scope, pg_database.datfrozenxid summarizes the oldest relevant boundary across relations in the database. Its age is useful for identifying databases that are approaching wraparound protection thresholds.

Vacuum has ordinary and aggressive scanning modes

A routine vacuum can skip pages when metadata shows that visiting them is unnecessary for its current work. Freezing eventually requires broader coverage because every page that might contain old unfrozen transaction IDs must be accounted for.

When a table’s frozen-XID age reaches the configured aggressive-vacuum threshold, vacuum performs an aggressive scan. Such a scan visits pages that might contain unfrozen XIDs or multixact IDs even when those pages do not need ordinary dead-tuple cleanup.

This separates two maintenance pressures that can otherwise look similar. A table with little churn can have almost no reclaimable dead space while still requiring periodic vacuum work because its surviving tuples are getting old in XID terms.

PostgreSQL 18 also permits normal vacuum to freeze some all-visible pages eagerly. That can move freezing work earlier and reduce the amount left for a later full-relation freezing pass.

Autovacuum has a wraparound duty independent of table churn

Autovacuum is often associated with insert, update, and delete thresholds, but anti-wraparound vacuuming follows transaction age. A mostly static table cannot be ignored forever merely because its rows rarely change.

autovacuum_freeze_max_age establishes the age at which PostgreSQL forces autovacuum activity to prevent a relation from retaining transaction IDs too close to the wraparound boundary. This protection can trigger even when ordinary autovacuum thresholds based on tuple changes would not.

Disabling autovacuum for a table therefore does not remove every automatic vacuum path. PostgreSQL retains anti-wraparound behavior because transaction ID exhaustion is a correctness risk rather than an optional maintenance preference.

The practical consequence is that maintenance capacity has to account for old, large, quiet relations as well as high-churn tables. A large archival table may need substantial scanning for freezing despite producing very few dead tuples.

Old snapshots can restrict freezing progress

Vacuum cannot discard transaction visibility information that may still be needed by an old snapshot. Long-running transactions, prepared transactions, and replication-related horizons can keep old transaction state relevant.

That can prevent vacuum from advancing age boundaries as far as expected. Repeated vacuum activity without corresponding progress in frozen-XID age can therefore point to a retained horizon rather than insufficient vacuum frequency alone.

The exact blocker depends on the database state. PostgreSQL exposes transaction, prepared-transaction, and replication-slot views that make retained XID horizons observable. Treating every age problem as a request for a more aggressive vacuum can miss the condition holding the horizon back.

VACUUM FREEZE changes the cutoff, not the XID architecture

VACUUM (FREEZE) requests aggressive freezing by using the oldest transaction that can safely be frozen as the cutoff. It does not convert the database to a different transaction-ID scheme, and it does not remove future vacuum requirements after new writes create new tuple versions.

That makes FREEZE a specialized control rather than a routine substitute for healthy autovacuum. On a large relation it can cause work earlier than normal age settings would require.

The durable protection comes from keeping frozen-XID ages bounded over time. One manual command can advance the boundary, but continued transaction allocation means maintenance must continue as the cluster evolves.

Transaction age is an operational resource

XID age behaves differently from disk consumption. A table can occupy a stable number of bytes and still move steadily toward an anti-wraparound vacuum as unrelated transactions consume IDs elsewhere in the cluster.

That makes age(relfrozenxid) and age(datfrozenxid) useful alongside familiar vacuum statistics. They expose a maintenance deadline that tuple-change counters alone cannot represent.

Tuple freezing is the bridge between PostgreSQL’s finite transaction identifier space and long-lived MVCC data. As long as vacuum can advance the frozen horizon before old XIDs cross the comparison boundary, rows can outlive many complete turns of the transaction counter without losing their place in visibility history.