PostgreSQL Transaction ID Wraparound
The failure that takes a database offline to protect it. This cluster is 899 transactions old, so the exercise is the arithmetic and the monitoring query - and why freezing one table moved nothing.
Troubleshooting Guide 46 of 47 Advanced
- OSUbuntu 26.04 LTS (resolute)
- PostgreSQL18.6-0ubuntu0.26.04.1
- PackagingDebian/Ubuntu (pg_ctlcluster, /etc/postgresql)
- TimeAbout 17 min
- Reviewed27 August 2026
Written against the versions above. `vacuum_failsafe_age` arrived in PostgreSQL 14 and skips index cleanup to finish faster in an emergency. PostgreSQL 18 reports `eagerly scanned` pages in `VACUUM VERBOSE`, which earlier versions do not.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| db-a01 | 192.168.0.81 | Ubuntu 26.04 LTS | Primary / Source / Replica Set Member 1 | 2 Core | 4 GB | 50 GB |
Before you start
- Any running cluster. Nothing here is destructive.
sudo -u postgresfor the cluster-wide views.
-
Measure how much of the budget is gone
PostgreSQL stamps every row with the id of the transaction that created it. Transaction ids are 32-bit and circular, so "older than" is decided over a 2-billion window. If a row's stamp fell more than 2 billion behind, it would start looking like it came from the future and become invisible.
PostgreSQL will not allow that. It refuses new transactions first - the database goes read-only rather than corrupting.
age(datfrozenxid)is how far behind the oldest unfrozen row is. Every database here is at 155, which is 0.0000% of the budget.pg_current_xact_id()is 899 - this cluster has run 899 transactions in its entire life.Nothing in a lab can reach wraparound, and simulating it would take days of burning transactions. What is worth having is the query, the arithmetic, and knowing which numbers mean trouble.
bash Example session sudo -u postgres psql -c "SELECT datname, age(datfrozenxid) AS xid_age, round(100.0 * age(datfrozenxid) / 2147483648, 4) AS pct_of_budget FROM pg_database ORDER BY xid_age DESC" datname | xid_age | pct_of_budget---------------+---------+--------------- postgres | 155 | 0.0000 template1 | 155 | 0.0000 template0 | 155 | 0.0000 appdb_partial | 155 | 0.0000 appdb | 155 | 0.0000(5 rows)sudo -u postgres psql -c "SELECT pg_current_xact_id() AS current_xid, txid_current() AS legacy_view" current_xid | legacy_view-------------+------------- 899 | 899(1 row)Expected resultAge 155 across every database; current xid 899.
Success conditionYou can measure wraparound distance on any cluster in one query.
-
The thresholds that act before you have to
Four numbers do the work:
autovacuum_freeze_max_age200,000,000 - at this age autovacuum vacuums the table whether or not autovacuum is switched on. It is the safety net, and at 200 million it fires with 90% of the budget still unused.vacuum_freeze_min_age50,000,000 - rows younger than this are left alone, because freezing a row that is about to be updated again is wasted work.vacuum_freeze_table_age150,000,000 - past this, an ordinaryVACUUMupgrades itself to a whole-table scan instead of using the visibility map.vacuum_failsafe_age1,600,000,000 - the emergency. Vacuum abandons index cleanup and cost delays and does nothing but freeze.The gap between 200 million and 2 billion is the margin. Wraparound emergencies are not the result of a missing setting; they are the result of something blocking vacuum for a very long time - an abandoned prepared transaction, an orphaned replication slot, or a session that opened a transaction in March.
bash Example session sudo -u postgres psql -c "SELECT name, setting FROM pg_settings WHERE name IN ('autovacuum_freeze_max_age','vacuum_freeze_min_age','vacuum_freeze_table_age','vacuum_failsafe_age','autovacuum_multixact_freeze_max_age','autovacuum')" name | setting-------------------------------------+------------ autovacuum | on autovacuum_freeze_max_age | 200000000 autovacuum_multixact_freeze_max_age | 400000000 vacuum_failsafe_age | 1600000000 vacuum_freeze_min_age | 50000000 vacuum_freeze_table_age | 150000000(6 rows)Expected result200M, 50M, 150M and 1.6B, with autovacuum on.
Success conditionYou know which threshold acts at which age.
-
Find the table that is furthest behind
age(relfrozenxid)per table is where the number actually comes from.ts_demo102,customers90,events51 - all trivially small here, but the shape is what you would run on a real cluster.The second query is the useful form: 199,999,949 transactions before
eventsgets an anti-wraparound vacuum whether anyone wants one or not.On a busy system that number is the warning. Divide it by your transaction rate and you have the time until a forced vacuum on a table that may be very large - which is a thing to schedule rather than be surprised by.
bash Example session psql -d appdb -c "SELECT relname, age(relfrozenxid) AS xid_age, pg_size_pretty(pg_relation_size(oid)) AS size FROM pg_class WHERE relkind = 'r' AND relnamespace = 'public'::regnamespace ORDER BY xid_age DESC LIMIT 6" relname | xid_age | size-----------+---------+------------ ts_demo | 102 | 8192 bytes customers | 90 | 688 kB events | 51 | 35 MB(3 rows)psql -d appdb -c "SELECT relname, age(relfrozenxid) AS xid_age, current_setting('autovacuum_freeze_max_age')::bigint - age(relfrozenxid) AS xids_until_forced_vacuum FROM pg_class WHERE relname = 'events'" relname | xid_age | xids_until_forced_vacuum---------+---------+-------------------------- events | 51 | 199999949(1 row)Expected resultPer-table ages, and about 200 million transactions of headroom.
Success conditionYou can rank tables by wraparound risk.
-
Freeze a table and read the VERBOSE output
VACUUM (FREEZE, VERBOSE)freezes everything it can rather than respectingvacuum_freeze_min_age. The age goes 51 to 0.The output is worth reading line by line:
aggressively vacuuming-FREEZEforces the whole-table scan.new relfrozenxid: 900, which is 51 XIDs ahead of previous value- the table's horizon moved forward by exactly the age it had.frozen: 2 pages from table (0.04% of total) had 3 tuples frozen- almost nothing needed freezing, because almost everything already was.visibility map: 4540 pages set all-visible, 4540 pages set all-frozen- this is the real product. All-frozen pages are skipped entirely by future vacuums, which is what stops freezing cost from growing with table size.WAL usage: 4555 records, 4554 full page images, 37344570 bytes- 37 MB of WAL to freeze a 35 MB table. Freezing is not free, and on a large table it is a significant write burst that has to be archived and replicated too.bash Example session psql -d appdb -c "SELECT age(relfrozenxid) AS before FROM pg_class WHERE relname = 'events'" before-------- 51(1 row)psql -d appdb -c "VACUUM (FREEZE, VERBOSE) events"INFO: aggressively vacuuming "appdb.public.events"INFO: launched 2 parallel vacuum workers for index cleanup (planned: 2)INFO: finished vacuuming "appdb.public.events": index scans: 0pages: 0 removed, 4543 remain, 4543 scanned (100.00% of total), 0 eagerly scannedtuples: 0 removed, 400000 remain, 0 are dead but not yet removableremovable cutoff: 900, which was 0 XIDs old when operation endednew relfrozenxid: 900, which is 51 XIDs ahead of previous valuefrozen: 2 pages from table (0.04% of total) had 3 tuples frozenvisibility map: 4540 pages set all-visible, 4540 pages set all-frozen (0 were all-visible)index scan bypassed: 3 pages from table (0.07% of total) have 5 dead item identifiersindex "idx_events_payload": pages: 114 in total, 0 newly deleted, 1 currently deleted, 1 reusableavg read rate: 318.770 MB/s, avg write rate: 311.589 MB/sbuffer usage: 4663 hits, 4661 reads, 4556 dirtiedWAL usage: 4555 records, 4554 full page images, 37344570 bytes, 0 buffers fullsystem usage: CPU: user: 0.01 s, system: 0.01 s, elapsed: 0.11 sINFO: aggressively vacuuming "appdb.pg_toast.pg_toast_16513"INFO: finished vacuuming "appdb.pg_toast.pg_toast_16513": index scans: 0pages: 0 removed, 0 remain, 0 scanned (100.00% of total), 0 eagerly scannedtuples: 0 removed, 0 remain, 0 are dead but not yet removableremovable cutoff: 900, which was 0 XIDs old when operation endednew relfrozenxid: 900, which is 51 XIDs ahead of previous valuefrozen: 0 pages from table (100.00% of total) had 0 tuples frozenvisibility map: 0 pages set all-visible, 0 pages set all-frozen (0 were all-visible)index scan not needed: 0 pages from table (100.00% of total) had 0 dead item identifiers removedavg read rate: 104.167 MB/s, avg write rate: 0.000 MB/sbuffer usage: 32 hits, 1 reads, 0 dirtiedWAL usage: 1 records, 0 full page images, 258 bytes, 0 buffers fullsystem usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 sVACUUMpsql -d appdb -c "SELECT age(relfrozenxid) AS after FROM pg_class WHERE relname = 'events'" after------- 0(1 row)sudo -u postgres psql -c "SELECT datname, age(datfrozenxid) AS xid_age FROM pg_database WHERE datname = 'appdb'" datname | xid_age---------+--------- appdb | 156(1 row)Expected result
eventsage 0, and the database age unchanged at 156.Success conditionYou can read what a freeze actually did from VERBOSE output.
-
Why the database age did not move
eventsis at 0 andappdbis still at 156.datfrozenxidis the minimum across every relation in the database, and listing them shows what is holding it:pg_type,pg_toast_2604,pg_toast_1255- system catalogues, not user tables.This is the trap. Someone watching a wraparound warning freezes the obvious big table, sees the database age refuse to move, and concludes freezing does not work. The database is only as young as its oldest relation, and that is frequently a catalogue or a TOAST table nobody thought about.
vacuumdb --freeze --dbname=appdbcovers everything, and the database age drops to 0 immediately.In a real emergency this is the command, run per database - and the databases that get forgotten are
template1andpostgres, which have their own ages and can trigger a cluster-wide shutdown on their own.bash Example session sudo -u postgres psql -d appdb -c "SELECT relname, age(relfrozenxid) AS xid_age FROM pg_class WHERE relkind IN ('r','t','m') AND age(relfrozenxid) > 0 ORDER BY xid_age DESC LIMIT 5" relname | xid_age---------------+--------- pg_toast_2604 | 156 pg_toast_1255 | 156 pg_type | 156 pg_toast_1247 | 156 pg_toast_2606 | 156(5 rows)sudo -u postgres vacuumdb --freeze --quiet --dbname=appdbsudo -u postgres psql -c "SELECT datname, age(datfrozenxid) AS xid_age FROM pg_database WHERE datname = 'appdb'" datname | xid_age---------+--------- appdb | 0(1 row)Expected resultCatalogues at 156, then the database at 0 after
vacuumdb --freeze.Success conditionYou will freeze the whole database rather than one table.
-
The query to put on a dashboard
Three columns: current age, transactions until a forced anti-wraparound vacuum, and transactions until wraparound itself.
appdbat 0 has the full 2,147,483,648. The others sit at 156 - includingtemplate0andtemplate1, which nobody writes to and which still age, because age is measured against the cluster's transaction counter rather than against their own activity.Alert on
until_forcedfalling, not onagerising - it is the same signal expressed as headroom, and it does not need a threshold updated wheneverautovacuum_freeze_max_agechanges.If it ever does start climbing, the cause is almost never vacuum being too slow. Look for the thing blocking it:
pg_stat_activityordered byxact_start,pg_prepared_xacts, andpg_replication_slotswhereactive = false.bash Example session sudo -u postgres psql -c "SELECT datname, age(datfrozenxid) AS xid_age, current_setting('autovacuum_freeze_max_age')::bigint - age(datfrozenxid) AS until_forced, 2147483648 - age(datfrozenxid) AS until_wraparound FROM pg_database ORDER BY xid_age DESC" datname | xid_age | until_forced | until_wraparound---------------+---------+--------------+------------------ postgres | 156 | 199999844 | 2147483492 template1 | 156 | 199999844 | 2147483492 template0 | 156 | 199999844 | 2147483492 appdb_partial | 156 | 199999844 | 2147483492 appdb | 0 | 200000000 | 2147483648(5 rows)Expected resultHeadroom per database,
appdbwith the full budget.Success conditionYou have a wraparound check that needs no interpretation.
Troubleshooting
database is not accepting commands to avoid wraparound data loss.Why: The cluster hit the hard limit. It is read-only to protect itself.
Fix:Single-user mode and
VACUUM FREEZE, per the message. Then find what blocked vacuum.Froze the big table and the database age did not move.
Why:
datfrozenxidis the minimum over every relation, catalogues included.Fix:
vacuumdb --freezeon the whole database.Age keeps climbing although autovacuum is on and running.
Why: Something holds the xmin horizon: an old transaction, a prepared transaction, an inactive slot.
Fix:Check all three. Vacuum cannot freeze past the oldest thing still watching.
template0is the oldest database and cannot be connected to.Why: It is marked as not allowing connections.
Fix:
vacuumdb --freeze --allhandles it, or temporarily allow connections.
Official sources
- PostgreSQL 18 Documentation - Client Authentication
- PostgreSQL 18 Documentation - Server Start-up Failures
- PostgreSQL 18 Documentation - Continuous Archiving
- PostgreSQL 18 Documentation - Preventing Transaction ID Wraparound Failures
- PostgreSQL 18 Documentation - Reliability and the Write-Ahead Log
- PostgreSQL 18 Documentation - pg_checksums