PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 22: VACUUM

Freezing and transaction ID wraparound

Level 5: Internals20 min

Transaction IDs are 32-bit and wrap around after about 4 billion. To keep old rows visible forever, VACUUM freezes them. If freezing falls behind for too long, PostgreSQL protects data by refusing new transactions, which is a full outage.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Explain why XIDs wrap and what freezing does
  • Monitor age(datfrozenxid) and age(relfrozenxid)
  • Know the settings and the emergency response

conceptThe concept

XIDs are compared modulo 232: each transaction sees about 2 billion XIDs in the past and 2 billion in the future. A row whose xmin falls more than 2 billion behind would suddenly look like it was created in the future and disappear. Freezing marks a tuple's xmin as "committed and older than everything", so its age no longer matters.

Every table has relfrozenxid: all XIDs older than it are frozen. age(relfrozenxid) is how many transactions have run since. When it exceeds autovacuum_freeze_max_age (200 million), autovacuum runs an aggressive anti-wraparound vacuum on that table, even if autovacuum is disabled, and that vacuum will not cancel itself for lock conflicts.

Near 2 billion, PostgreSQL warns, then stops assigning new XIDs ("database is not accepting commands that assign new transaction IDs") until a VACUUM freezes enough rows. The visibility map's all-frozen bit lets later vacuums skip frozen pages.

whyWhy it matters

Wraparound shutdowns are among the most painful PostgreSQL incidents: high-write systems can burn through 200 million XIDs in days, and the forced vacuum of a multi-terabyte table can take many hours.

Use caseReal-world scenario

A monitoring alert fires when age(datfrozenxid) exceeds 1 billion. The DBA finds a stuck replication slot pinning xmin, drops it, and lets the aggressive vacuum complete.

Try itExamples

Run and edit these in the interactive lesson.

How close is each database to wraparound?
SELECT datname,
       age(datfrozenxid) AS xid_age,
       round(100.0 * age(datfrozenxid) / 2000000000, 6) AS pct_to_shutdown,
       mxid_age(datminmxid) AS multixact_age
FROM pg_database
ORDER BY xid_age DESC;
SHOW autovacuum_freeze_max_age;
Oldest tables first
SELECT c.relname, 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') AND c.relnamespace <> 'pg_catalog'::regnamespace
ORDER BY age(c.relfrozenxid) DESC
LIMIT 8;
Frozen tuples in a page
SELECT lp, t_xmin, raw_flags, combined_flags
FROM heap_page_items(get_raw_page('employees', 0)),
     LATERAL heap_tuple_infomask_flags(t_infomask, t_infomask2)
LIMIT 3;
The seed ran VACUUM (FREEZE). Frozen is not a separate bit: it is XMIN_COMMITTED and XMIN_INVALID set together, which combined_flags reports as HEAP_XMIN_FROZEN. The original xmin number is kept for debugging.

internalsUnder the hood

Freezing sets a flag in the tuple header (both committed and invalid bits, called frozen) instead of rewriting xmin. Multixact IDs, used when several transactions lock the same row, have their own wraparound counter (mxid_age(datminmxid)) and the same kind of protection. Since 16, vacuum_freeze_min_age also interacts with opportunistic page-level freezing.

Avoid / preferDo this, not that

Avoid
SELECT pg_cancel_backend(pid) FROM pg_stat_activity
WHERE query LIKE 'autovacuum: VACUUM%(to prevent wraparound)';
Prefer
-- let it finish; raise autovacuum_vacuum_cost_limit if it is too slow

Anti-wraparound vacuums restart immediately when cancelled, and they are what keeps the cluster from shutting down to protect itself.

Avoid
-- an abandoned replication slot or prepared transaction left in place
Prefer
SELECT slot_name, active FROM pg_replication_slots;
SELECT gid, prepared FROM pg_prepared_xacts;

Both hold back the oldest xmin, so freezing cannot advance however often VACUUM runs.

PitfallsCommon mistakes

  • Ignoring XID age in monitoring because "autovacuum handles it". It does, until a long transaction or stale slot stops it.
  • Restarting the server or killing the anti-wraparound autovacuum to "fix" slowness. It will start again and the age keeps growing.
  • Running single-user mode first in an emergency. Modern guidance: remove the blocker (old slots, prepared transactions, long sessions), then run VACUUM in normal mode.

LimitsWhen not to use it

Do not run VACUUM FREEZE on every table every night "to be safe". It writes every page again, generating heavy I/O and WAL. Monitor age and let autovacuum freeze incrementally.

SecuritySecurity notes

Reporting and BI roles that leave transactions open can block freezing for the whole cluster. Set idle_in_transaction_session_timeout and statement timeouts for such roles.

ChallengeTry it yourself

For each table in public, show its XID age and how many transactions remain before autovacuum forces an anti-wraparound vacuum.

Show one solution
One solution
SELECT c.oid::regclass AS table_name,
       age(c.relfrozenxid) AS xid_age,
       current_setting('autovacuum_freeze_max_age')::bigint - age(c.relfrozenxid) AS xids_until_forced
FROM pg_class c
WHERE c.relkind IN ('r', 'm') AND c.relnamespace = 'public'::regnamespace
ORDER BY xid_age DESC;

InterviewQuestions you should be able to answer

What is transaction ID wraparound and how does PostgreSQL prevent it?
XIDs are 32-bit and compared circularly, so a tuple more than about 2 billion transactions old would appear to be in the future and vanish. VACUUM freezes old tuples, marking them visible to all regardless of XID. Each table tracks relfrozenxid; when age(relfrozenxid) passes autovacuum_freeze_max_age (200M by default), an aggressive anti-wraparound autovacuum runs. If age approaches 2 billion, the server stops assigning XIDs to protect data until enough has been frozen.
XID age is climbing on one database. How do you investigate?
Find the oldest tables with age(relfrozenxid). Check what prevents freezing: long-running or idle-in-transaction sessions (pg_stat_activity backend_xmin), replication slots with old xmin or catalog_xmin, orphaned prepared transactions (pg_prepared_xacts), and whether autovacuum workers reach that table (are they all busy on other big tables, are they throttled). Remove the blocker, then let or run VACUUM (FREEZE) on the oldest tables, raising maintenance_work_mem and cost limits if needed.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation