Databases
Part 5 of 6 · MVCC & isolationVACUUM, Bloat & XID Wraparound
Dead MVCC versions stay until no snapshot can see them. VACUUM reclaims them and freezes XIDs; lag plus long txns cause bloat and wraparound that refuses writes.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Overview
MVCC never overwrites a live row in place for an UPDATE. It stamps xmax on the old version and inserts a new one. DELETE only stamps xmax. Those dead versions are the tax: they occupy heap and index until VACUUM proves no snapshot still needs them.
Three problems, one mechanism:
- Bloat — dead tuples and holes after vacuum-too-late; seq scans and cache miss more pages.
- Horizon lag — a single idle-in-transaction session holds back freeze and removal for the whole cluster.
- XID wraparound — 32-bit transaction IDs must be frozen; ignore that and Postgres stops writes to protect you.
This is the ops half of xmin / xmax visibility. Isolation choice does not delete versions; vacuum does.
Why autovacuum plus freeze wins
VACUUM FULL looks like a cleanup hammer. It rewrites the table under an AccessExclusiveLock and returns bytes to the OS. It is the wrong default. The winning path is routine vacuum that reuses pages in place, plus anti-wraparound freeze, plus killing long txns.
| Path | File size | Locks | Protects wraparound? |
|---|---|---|---|
| Do nothing | Grows forever | None until the crash | No — then read-only |
| VACUUM FULL on a hot table | Shrinks | Exclusive; outage-shaped | Only as a side effect |
| Winner: autovacuum + freeze + short txns | Reuse in-place (file may stay large) | Share-ish; concurrent reads/writes | Yes, if freeze keeps up |
- 1
dead xmax versions → seq scan pain then wraparound
Skip vacuum; keep long transactions
n_dead_tup climbs. Indexes hold dead TIDs. Oldest xmin never freezes. Eventually the cluster refuses INSERT/UPDATE.
- 2
horizon = oldest snapshot → dead tuples reusable
Winner: regular VACUUM + freeze, short txns
Autovacuum workers reclaim versions no snapshot can see, set freeze XIDs, update the visibility map. idle_in_transaction_session_timeout is part of the vacuum plan.
- ?
VACUUM FULL only for true file-shrink
After a delete of 80% of a table that will not refill, FULL (or CLUSTER / pg_repack) returns space to the OS. Not a substitute for scale_factor tuning on an UPDATE-heavy table that will bloat again tomorrow.
Tuple lifecycle
- INSERT — new heap tuple,
xmin =current XID,xmax = 0(live). - UPDATE — new tuple with new
xmin; old tuplexmax =current XID (dead once that XID commits). - DELETE — set
xmaxonly; nothing is physically gone. - Visibility — a snapshot sees a version if the inserter is committed-as-of-snapshot (or is you) and the deleter is not. Same rule as the hub.
Dead committed versions remain on disk. Indexes still point at them until vacuum. That is heap bloat and index bloat.
HOT (heap-only) updates that stay on the same page reduce index churn. Cold updates that move pages leave more dead index entries.
The vacuum horizon
VACUUM can remove a dead tuple only when every still-running snapshot is recent enough that it cannot see that version. The oldest transaction’s snapshot is the horizon.
A single BEGIN then a coffee break (idle in transaction) pins the horizon. Every other session pays: dead tuples pile up, autovacuum “runs” but cannot prune what that snapshot might still read, disk and cache suffer. This is a fleet incident, not a style note.
Row locks (FOR UPDATE) held across think-time have the same shape: the txn is open, the horizon does not move.
Sequence
- 1
Application
Step 1 – writes create dead versions, XID ticks
- 2
Application → Heap
UPDATE / DELETE
- 3
Autovacuum
Step 2 – launcher compares n_dead_tup to threshold
- 4
Heap → Autovacuum
stats: dead tuples, freeze age
- 5
Autovacuum
Step 3 – regular VACUUM prunes dead, updates VM and FSM
- 6
Autovacuum → Heap
VACUUM
- 7
Autovacuum
Step 4 – anti-wraparound VACUUM FREEZE
- 8
Autovacuum → Heap
freeze old xmin
- 9
Heap
Step 5 – frozen xid recorded, age resets
- 10
DBA
Step 6 – VACUUM FULL only if the file must shrink
- 11
DBA → Heap
VACUUM FULL
Lesson map
VACUUM, Bloat & XID Wraparound
Dead MVCC versions stay until no snapshot can see them. VACUUM reclaims them and freezes XIDs; lag plus long txns cause bloat and wraparound that refuses writes.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB app["Application"] pg["Heap"] av["Autovacuum"] dba["DBA"] app -->|UPDATE / DELETE| pg pg -->|stats: dead| av av -->|VACUUM| pg av -->|freeze old xmin| pg dba -->|VACUUM FULL| pg
Autovacuum
The launcher spawns up to autovacuum_max_workers. Each worker either vacuums (dead tuples), analyzes, or runs anti-wraparound freeze.
Trigger-ish formula: vacuum when
n_dead_tup > autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * n_live_tup
Defaults (50 + 0.2 * live) are too lazy for a 100M-row table that updates 1% — you wait for 20M dead tuples. Per-table storage parameters: lower autovacuum_vacuum_scale_factor on hot tables.
Throttle: vacuum_cost_delay / vacuum_cost_limit (and autovacuum variants) so vacuum yields I/O. maintenance_work_mem sizes sort/index cleanup. autovacuum_naptime is how often the launcher looks.
autovacuum_freeze_max_age (default 200 million) and autovacuum_multixact_freeze_max_age force freeze workers even if dead-tuple counts look fine.
XID wraparound
XIDs are 32-bit. About two billion usable IDs before numbers wrap. Visibility compares ages, not raw integers; that only works if old tuples are frozen (marked older-than-all, FrozenTransactionId) so they stay visible after wrap.
When the oldest unfrozen XID’s age approaches freeze_max_age, autovacuum must freeze. If it cannot (horizon pinned, vacuum starved, autovacuum = off), age keeps climbing. Before wrap would corrupt visibility, Postgres refuses writes. That is a wraparound shutdown: availability incident, not a slow query.
Stages you should name in an interview (numbers are the usual defaults; check your version):
- Autovacuum freeze — age past
autovacuum_freeze_max_age(~200M). Workers go aggressive even if dead-tuple counts look fine. - Warnings in the log — age toward ~1B; “database must be vacuumed.” You still have time. Find the oldest
backend_xmin/ prepared txn / slot and freeze. - Wraparound protection — remaining XID budget is critically small; the engine stops INSERT/UPDATE/DELETE (and some DDL) until freeze succeeds. Reads may still work. This is a SEV-1 for a write-heavy service.
Multixacts (used for FOR SHARE and multi-locker states) have their own freeze age. Failure mode: multixact members limit exceeded. Vacuum those tables too. Replication slots and prepared transactions pin XIDs the same way idle sessions do — look there when autovacuum “cannot freeze.”
VACUUM vs VACUUM FULL
| Command | What it does | Concurrent? | OS file size |
|---|---|---|---|
VACUUM | Prune dead tuples, freeze, update VM/FSM | Yes (with writers, mostly) | Pages reusable; file often unchanged |
VACUUM (ANALYZE) | Plus planner stats | Same | Same |
VACUUM FREEZE | Aggressive freeze | Same | Same |
VACUUM FULL | Rewrite table and indexes | AccessExclusiveLock | Shrinks |
The interview trap: “VACUUM shrinks the table.” Regular VACUUM does not. It marks free space for future INSERTs (FSM). If you deleted 90% and will not refill, you need FULL, pg_repack, or a new table.
Visibility map: pages all-visible skip heap fetches on index-only scans. Vacuum is what sets those bits. Skip vacuum, lose index-only.
Free space map: where to put the next INSERT without extending the file.
Bloat patterns and monitoring
- High-frequency UPDATE of wide rows → many dead versions per live row.
- DELETE without INSERT → holes vacuum can reuse but FULL would shrink.
- Long reports / SSI transactions / orphaned sessions → horizon stall.
- Indexes: dead TIDs until vacuum; bloat estimates from
pg_class.relpagesvs live tuples.
Watch:
pg_stat_user_tables:n_dead_tup,n_live_tup,last_autovacuum,vacuum_countage(datfrozenxid)/age(relfrozenxid)— wraparound fuel gaugepg_stat_activity:idle in transaction, oldbackend_xmin/xact_start- relation size vs live tuples for bloat
Alert before freeze_max_age, not after writes fail. dead / (live+dead) > 0.2 on a hot table is a tuning ticket, not a FULL panic.
-- Health snapshot (run on a replica or with a short timeout).
SELECT schemaname, relname, n_live_tup, n_dead_tup,
last_autovacuum, age(relfrozenxid) AS xid_age
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
SELECT datname, age(datfrozenxid) AS db_xid_age
FROM pg_database
ORDER BY age(datfrozenxid) DESC;The first query is bloat. The second is wraparound. A table with low n_dead_tup can still have a dangerous xid_age if it is insert-mostly and never frozen.
Tuning knobs
- Lower
autovacuum_vacuum_scale_factor(and maybe threshold) per hot table. - Raise
autovacuum_max_workerscarefully (they share I/O). vacuum_cost_limitup if vacuum never finishes; down if it fights OLTP.maintenance_work_memfor big indexes.idle_in_transaction_session_timeout— vacuum policy in disguise.- Never leave
autovacuum = offon a primary that takes writes.
Partitioning shrinks vacuum scope: a partition’s freeze and bloat are local. Still freeze the cluster’s datfrozenxid.
In-memory vacuum (run this)
Simulate xmin/xmax, a horizon, freeze, wraparound refusal. I/O contract: heap + snapshots in; prune/freeze/refuse-writes out.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Deep dive · VM, FSM, and why index-only scans need vacuum
After pruning a page of all dead tuples and confirming every remaining tuple is visible to all, vacuum sets the all-visible bit in the visibility map. Index-only scans may then skip the heap. A table that “has a covering index” but is never vacuumed still heap-fetches. FSM records free bytes per page so the next INSERT reuses a hole instead of extending relpages. FULL rebuilds both maps from a new file. Anti-wraparound vacuum is allowed to run more aggressively (even skip some cost delay) because wraparound is data-loss-adjacent. Monitor age(datfrozenxid) from pg_database and per-table age(relfrozenxid) — the cluster freeze is the min of those.
Interview Q&A
Why does VACUUM exist in an MVCC engine?
Answer
UPDATE/DELETE leave old versions for snapshots that still need them. Vacuum removes versions no snapshot can see, frees index TIDs, updates VM/FSM, and freezes old XIDs. Without it you get bloat, failed index-only scans, and wraparound. Readers-not-blocking-writers is paid for in garbage collection.
Does VACUUM shrink the table file?
Answer
Regular VACUUM does not return space to the OS; it marks pages reusable. VACUUM FULL rewrites the relation under an exclusive lock and shrinks the file. Saying “just VACUUM to reclaim disk” is the trap. Use FULL / pg_repack when the table will not refill.
How do long-running or idle-in-transaction sessions hurt everyone?
Answer
They pin the vacuum horizon. Dead tuples that snapshot might still see cannot be pruned. Autovacuum appears “broken,” n_dead_tup climbs, cache misses, freeze age follows the oldest xmin. idle_in_transaction_session_timeout and short transactions are vacuum tools. Same story as holding FOR UPDATE during a pause.
What is XID wraparound and what does the engine do if freeze fails?
Answer
32-bit XIDs wrap. Frozen tuples stay visible after wrap. If the oldest unfrozen XID gets too old, Postgres must freeze. If it cannot, it refuses writes to avoid visibility corruption. This is an availability incident. Watch age(datfrozenxid) vs autovacuum_freeze_max_age.
VACUUM vs anti-wraparound vacuum vs VACUUM FULL?
Answer
Regular: prune dead tuples when counts exceed threshold + scale_factor * live. Anti-wraparound: freeze by XID age even if the table is not “bloated.” FULL: exclusive rewrite for file shrink. Different triggers, different pain.
Name the autovacuum knobs you actually change on a hot table.
Answer
Per-table autovacuum_vacuum_scale_factor (and threshold) down so 20% of a huge table is not the trigger. autovacuum_max_workers, vacuum_cost_limit / delay for I/O. maintenance_work_mem. Freeze ages if you understand the wraparound budget. Never autovacuum = off on a writer.
What is a multixact and why freeze it?
Answer
Multi-transaction IDs record several lockers of one tuple (FOR SHARE and friends). They wrap too (autovacuum_multixact_freeze_max_age). Neglect → “multixact members limit exceeded.” Row-lock heavy tables need vacuum as much as UPDATE-heavy ones.
Which stats do you look at for bloat and wraparound?
Answer
pg_stat_user_tables.n_dead_tup vs n_live_tup, last autovacuum time, age(relfrozenxid), age(datfrozenxid), pg_stat_activity idle-in-transaction, relation size vs live tuples. Alert at 150M XID age (or your SLA), not at wrap.
HOT updates vs cold updates?
Answer
HOT: new version on the same page, indexed columns unchanged — less index bloat. Cold: new page / indexed column changed — extra dead index entries until vacuum. Fillfactor can leave room for HOT. Vacuum still required.
How does this relate to isolation and SSI?
Pitfalls
Draw three versions of Alice: live v1, UPDATE to v2, vacuum after no snapshot needs v1. Add a dashed “idle txn” arrow that blocks prune. Write the autovacuum trigger inequality and plug in 100M live rows at default 0.2. List the difference between hitting freeze_max_age and hitting wraparound write refusal. Optional: what n_dead_tup would make you lower scale_factor instead of running FULL.
Go Deeper
- PostgreSQL — Routine vacuuming
- PostgreSQL — VACUUM
- PostgreSQL wiki — Transaction ID Wraparound Failures
Cluster: MVCC hub · Isolation levels · SSI vs SI · FOR UPDATE · Phantoms vs write skew
One-line takeaway: MVCC versions live until the oldest snapshot lets go — vacuum reuses that space and freezes XIDs; lag plus long txns become bloat and then a wraparound write freeze.