Transaction ID wraparound: why must PostgreSQL freeze old tuples? Advanced
Estimated reading time: 8 min read
Read first
Problem
Section titled “Problem”A catalog table is almost never updated and has n_dead_tup = 0, so the operations team assumes it needs no VACUUM. Yet PostgreSQL still runs an autovacuum “to prevent wraparound” on that table.
The reason: every row version carries the XID of the transaction that created it, and a normal XID is only 32 bits.
After enough transactions the counter wraps around; a tuple old enough that is still compared as a normal XID would be mistaken as coming from “the future” and become invisible.
PostgreSQL prevents this by freezing sufficiently old tuples. This is data-safety maintenance, not a performance optimization.
This article only contains read-only status queries. It does not simulate billions of transactions, lower the protection thresholds, or turn off autovacuum.
The XID age scale
Section titled “The XID age scale”age() counts the number of XIDs that have passed, not time. The thresholds below are PostgreSQL 17 defaults (all from the official docs), read top to bottom as the age grows:
XID age (age) |
Threshold | What happens |
|---|---|---|
| 0 | Just frozen | Nothing to do |
| 50 million | vacuum_freeze_min_age |
When VACUUM runs, it freezes tuples older than this |
| 150 million | vacuum_freeze_table_age |
The next VACUUM switches to an aggressive scan to advance relfrozenxid |
| 200 million | autovacuum_freeze_max_age |
Anti-wraparound autovacuum starts for that table, even one with no dead tuples |
| 1.6 billion | vacuum_failsafe_age |
Failsafe: VACUUM drops cost-based delay and skips index vacuuming to run as fast as possible |
| 40 million XIDs left | (from the wraparound point, i.e. age about 2.107 billion) | The server starts logging WARNING ... must be vacuumed within N transactions |
| 3 million XIDs left | (age about 2.144 billion) | The server refuses to assign new XIDs: every write stops |
The absolute limit is 2^31 = 2,147,483,648, i.e. about two billion XIDs “in the past”; the other half of the circle is treated as the future. Note that the configuration thresholds cap one another: for example, vacuum_freeze_min_age is effectively at most half of autovacuum_freeze_max_age.
Multixacts (used when several transactions lock the same row, see row-level locks) have their own counter and thresholds: autovacuum_multixact_freeze_max_age defaults to 400 million, vacuum_multixact_freeze_table_age to 150 million, failsafe at 1.6 billion.
The warning at 40 million remaining and refusal at 3 million remaining apply likewise. Systems with many foreign keys and FOR SHARE should also track mxid_age().
Core concepts
Section titled “Core concepts”- Freezing marks a sufficiently old tuple as always visible, instead of relying on XID comparison. Since PostgreSQL 9.4 it is a status bit; the displayed
xminmay keep the old number. pg_class.relfrozenxidis the oldest XID that may still be unfrozen in one relation;pg_database.datfrozenxidis the lowest across the whole database. It is a conservative marker maintained by VACUUM and cannot be replaced withMIN(xmin); do not scanxminacross the whole table to investigate.- Insert-only tables still need aggressive VACUUM because old tuples still carry old XIDs. PostgreSQL 18 lets a normal VACUUM eagerly freeze some all-visible pages (eager freezing, tuned with
vacuum_max_eager_freeze_failure_rate), which reduces large freeze waves but does not remove the need to freeze. - XIDs in use are 32-bit on PostgreSQL 17.
How it works
Section titled “How it works”Every transaction that needs an ID gets an XID from a 32-bit space. The ID cannot grow forever in a 32-bit field.
- A transaction that needs an XID receives a number in the 32-bit space.
- Tuples it creates carry that XID; as new transactions appear, their
age(xmin)grows. - VACUUM freezes eligible tuples, so their visibility no longer depends on ordinary XID comparison.
- If the table is not frozen in time, anti-wraparound autovacuum starts even without dead tuples; if that still fails, the server warns and then blocks statements needing new XIDs.
Alerting: queries and thresholds
Section titled “Alerting: queries and thresholds”SELECT datname, age(datfrozenxid) AS xid_age, 2147483648 - age(datfrozenxid) AS xid_remainingFROM pg_databaseORDER BY xid_age DESC;Alert on xid_age per database, and add the largest age(relfrozenxid) within each database:
| Level | Suggested threshold (with the 200M default) | Action |
|---|---|---|
| Observe | Above 200 million and dropping after each autovacuum round | Normal: autovacuum is doing its job |
| Warning | Above 500 million, or not dropping after an autovacuum round | Find what is holding back the horizon (below) |
| Page on-call | Above 1 billion | Not far from failsafe (1.6 billion); handle it the same day |
The 500 million and 1 billion thresholds are this article’s operational suggestions, not PostgreSQL numbers.
Also measure the rate of XID consumption (sample age(datfrozenxid) every few minutes) to convert to “how many days are left”: an age of 10 million can mean months on a light-write system or hours on a heavy-write one.
A script in the repo prints the configured thresholds, each database’s age, and the 10 oldest relations (with TOAST joined in):
docker compose -f examples/docker-compose.yml up -d postgresdocker compose -f examples/docker-compose.yml exec -T postgres psql -U hub -d hub < examples/src/postgres-wraparound/check-xid-age.sqlWhen the age does not drop: find what holds the horizon
Section titled “When the age does not drop: find what holds the horizon”Long-open transactions, prepared transactions, stale replication slots (and replicas with hot_standby_feedback on) hold back the XID horizon, so VACUUM finishes yet the age does not drop. Raising configuration thresholds only postpones the warning. Start with observation queries:
SELECT pid, state, xact_start, age(backend_xid) AS xid_age, age(backend_xmin) AS xmin_age, queryFROM pg_stat_activityWHERE backend_xid IS NOT NULL OR backend_xmin IS NOT NULLORDER BY xact_start NULLS LAST;
SELECT gid, prepared, age(transaction) AS xid_ageFROM pg_prepared_xacts ORDER BY prepared;
SELECT slot_name, active, age(xmin) AS xmin_age, age(catalog_xmin) AS catalog_xmin_ageFROM pg_replication_slots;XID columns can be NULL, and age(NULL) is also NULL: not an error. Dropping a slot may force a replica to be rebuilt; confirm with whoever operates the replica.
If the server is already emitting wraparound warnings, deal with whatever is holding the horizon, then run VACUUM on the right database with privileges sufficient to process the system catalogs too, following the instructions for your exact version.
VACUUM FULL is not a rescue: it rewrites the table, takes a strong lock, and may itself need an XID.
Real-world example
Section titled “Real-world example”Production checklist
Section titled “Production checklist”- Alert on
age(datfrozenxid)per database: warning at 500M, page at 1B (and the equivalent formxid_age()). - Monitor the largest
age(relfrozenxid)per table, including TOAST. - Record the daily XID consumption rate to know “how long is left”, not just the absolute number.
- Periodically review prepared transactions, inactive replication slots, and
idle in transactionsessions. - Huge insert-only tables: schedule
VACUUM (FREEZE)off-peak instead of waiting for aggressive autovacuum. - Do not turn off autovacuum, and do not use
VACUUM FULLas a rescue. - Have a runbook for when a WARNING already exists: who may terminate sessions, drop slots, run VACUUM.
Trade-offs
Section titled “Trade-offs”Freezing on schedule
- Rows outlive the life cycle of a 32-bit XID with correct visibility.
- Anti-wraparound autovacuum leaves a large buffer before the real limit.
age(relfrozenxid)andage(datfrozenxid)reveal trends through read-only queries.
Costs and operational risks
- Aggressive VACUUM scans many pages and costs more I/O than a normal VACUUM.
- Transactions, slots, or prepared transactions holding the horizon keep freezing from advancing.
- Raising thresholds or disabling autovacuum only delays the problem and shortens reaction time.
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”Self-check: (1) Why does an insert-only table still need aggressive VACUUM? (2) Does age(datfrozenxid) = 10 million tell you how many safe days remain? (3) How does the 1.6 billion failsafe change VACUUM’s behavior?