PostgreSQLVIII · VACUUM, Autovacuum and WraparoundVacuum
Transaction ID wraparound and anti-wraparound vacuum
What you'll learn
- Explain why a finite transaction id space requires freezing
- Recognise an anti-wraparound vacuum in the log and in pg_stat_activity
- Quantify the WAL cost of freezing before scheduling it
- Respond correctly to a cluster approaching the limit, including what not to do
Prerequisites
Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27
This is the failure that stops a PostgreSQL cluster accepting writes. It is fully preventable, it announces itself for weeks, and it still takes production systems down — because the warnings arrive on a database nobody was watching, or on a table nobody knew existed.
Why freezing is necessary
Transaction ids are 32 bits. About four billion values, and they wrap.
Visibility, from lesson VII-03, works by comparing a tuple’s xmin
against the current transaction id: older means potentially visible,
newer means not. That comparison is done in a circular space, where any
id is “in the past” for half the range and “in the future” for the other
half.
So a row written two billion transactions ago would, without
intervention, become invisible — not deleted, not corrupted, simply
unreadable, because its xmin has drifted into what now looks like the
future.
Freezing is the fix. A frozen tuple is marked as visible to
everything, permanently, and its xmin is no longer compared against
anything. Vacuum does this, and relfrozenxid records how far back the
guarantee extends for each relation.
The escalation ladder
Four thresholds, and the behaviour changes at each.
| Age | Setting | What happens |
|---|---|---|
| 50,000,000 | vacuum_freeze_min_age | A vacuum that visits a page freezes tuples older than this |
| 150,000,000 | vacuum_freeze_table_age | The next vacuum on the table scans it in full rather than skipping all-visible pages |
| 200,000,000 | autovacuum_freeze_max_age | Autovacuum forces an anti-wraparound vacuum regardless of anything else |
| 1,600,000,000 | vacuum_failsafe_age | Vacuum abandons throttling and skips index cleanup |
| ~2,000,000,000 | — | The server refuses new transactions |
The margin between 200 million and the shutdown point is roughly 1.8 billion transactions. On a cluster consuming a thousand transactions per second that is about three weeks.
Wraparound outages are never caused by a tight margin. They are caused by three weeks of a warning nobody read.
What an anti-wraparound vacuum looks like
This was reproduced on a test cluster by lowering
autovacuum_freeze_max_age to 100,000 and restarting — waiting for 200
million real transactions is not practical, and the log output is
identical.
$ docker logs rbpg-stor 2>&1 | grep "to prevent wraparound"LOG: automatic aggressive vacuum to prevent wraparound of table "postgres.public.narrow": index scans: 0
pages: 0 removed, 443 remain, 443 scanned (100.00% of total), 0 eagerly scanned
tuples: 0 removed, 100000 remain, 0 are dead but not yet removable
removable cutoff: 683715, which was 0 XIDs old when operation ended
new relfrozenxid: 683715, which is 682961 XIDs ahead of previous value
frozen: 443 pages from table (100.00% of total) had 100000 tuples frozen
visibility map: 0 pages set all-visible, 443 pages set all-frozen (443 were all-visible)
index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed
avg read rate: 30.324 MB/s, avg write rate: 29.789 MB/s
buffer usage: 480 hits, 453 reads, 445 dirtied
WAL usage: 887 records, 445 full page images, 3676020 bytes, 0 buffers full
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.11 sThe phrase is automatic aggressive vacuum to prevent wraparound of table. Put it in your log alerting.
Read tuples: 0 removed, 100000 remain. This vacuum deleted nothing. It
existed purely to freeze, and it rewrote every page of the table to do
it.
Every database’s age fell to zero after the pass, including template0
and template1, which were vacuumed in the same sweep:
LOG: automatic aggressive vacuum to prevent wraparound of table
"template1.pg_toast.pg_toast_3600": index scans: 0
The cost of freezing, measured
What anti-wraparound vacuum does differently
Three behaviours that ordinary autovacuum does not have.
It ignores autovacuum_enabled = false. A table with autovacuum
disabled is still force-vacuumed at autovacuum_freeze_max_age. This is
the backstop that prevents that setting from being fatal.
It does not yield its lock. Ordinary autovacuum cancels itself when
a session requests a conflicting lock. This one does not. A DROP TABLE
or ALTER TABLE behind it waits, and everything behind that waits too.
It scans the whole table. It cannot skip all-visible pages, because freezing is exactly what those pages need.
The response, in order
When a database’s age is climbing toward the threshold:
1. Find the relation. Include TOAST relations and shared catalogues.
SELECT c.oid::regclass AS relation, c.relkind,
age(c.relfrozenxid) AS xid_age,
pg_size_pretty(pg_total_relation_size(c.oid)) AS size
FROM pg_class c
WHERE c.relkind IN ('r','m','t')
ORDER BY age(c.relfrozenxid) DESC
LIMIT 20;
2. Determine why it is not being frozen. This is one of the six modes from lesson VIII-04, and mode 1 — a held horizon — is the most common. Freezing cannot advance past the oldest snapshot either.
3. Remove the obstacle. End the transaction, drop the slot, stop the DDL.
4. Freeze deliberately, in a window.
-- one table, supervised, with a cost delay so it does not saturate storage
SET vacuum_cost_delay = '2ms';
VACUUM (FREEZE, VERBOSE) big_table;
vacuumdb --freeze --jobs=N --analyze handles a whole database with
parallelism, which is the practical tool when several relations are
behind.
5. Verify. age(relfrozenxid) should fall to near zero for that
relation, and age(datfrozenxid) for the database should follow once
the minimum across all its relations moves.
What to take from this
- Transaction ids are finite and wrap; freezing makes old rows permanently visible.
- The margin from forced vacuuming to shutdown is roughly 1.8 billion transactions — weeks, not hours.
automatic aggressive vacuum to prevent wraparound of tableis the log string to alert on.- Freezing costs roughly the table’s size in WAL, in a burst.
- Anti-wraparound vacuum ignores the per-table disable and does not yield. Do not kill it without a plan to replace it.
pg_resetwalis never the answer to a warning. Single-user mode is the answer to an actual shutdown.- Track multixact age alongside transaction age.
Cross-course references
- Observability for Production Sysadmins — Part XIII (Rates and counters) covers projecting a deadline from an age series, and Part XVIII (Alerting rules) covers alerting on it early enough that the fix is a maintenance window rather than an outage.
- Linux for Production Sysadmins — Part LXXXI (Incident command) covers running the response to a cluster that has stopped accepting writes.
Quiz
Knowledge check · 6 questions
Q1. A 2 TB table has never been frozen and is about to receive its first anti-wraparound vacuum. Beyond the lock it will hold, what should be planned for?
Q2. During an incident, an autovacuum worker is blocking a deployment. Its entry in pg_stat_activity shows a query containing '(to prevent wraparound)'. What is the correct response?
Q3. A cluster with a heavily foreign-keyed schema and high concurrency begins issuing aggressive vacuums, yet age(datfrozenxid) across all databases is under ten million. What is the likely explanation?
Q4. Which behaviours distinguish an anti-wraparound vacuum from an ordinary autovacuum? Select all that apply.
Q5. pg_resetwal is an appropriate emergency tool when a cluster is approaching the transaction id limit and the aggressive vacuum is taking too long.
Q6. Explain why freezing is necessary at all, in terms of how visibility is decided.
Passing score: 75%. Answers are checked in this browser.