The Runtime Theory
ApplicationInternalsstorage

What happens when autovacuum runs?

A step-by-step walk from MVCC dead tuples and threshold triggers, through page scans and tuple removal, the visibility map, free space map, and xid freeze, to bloat.

The Runtime Theory Team4 min read07 steps

layer stack

Application

HWHardware
KKernel
RTRuntime
APPApplication
SYSSystem
CLIClient
NETNetwork
TLSCrypto
SRVServer

adjacent altitudes in this subsystem are still being traced

trace spine

  1. 01 UPDATE/DELETE leave dead tuples
  2. 02 Autovacuum triggers on dead-tuple count
  3. 03 Vacuum scans pages for dead tuples
  4. 04 Dead tuple space freed in place
  5. 05 Visibility map updated
  6. 06 Free space map refreshed
  7. 07 Xid freeze protects against wraparound
On this page

Vacuum is Postgres' janitor, and its work is an unavoidable consequence of MVCC: every UPDATE is secretly an INSERT plus a tombstone. The old row version must survive until no snapshot can see it, then it's garbage — and garbage costs space and makes scans slow. Vacuum is the mechanism that turns garbage back into reusable space. It never stops, it's always slightly behind, and understanding it is the difference between a 10GB table with a 1GB footprint and a 100GB table that's really 10GB of junk.

Step 1 — death by MVCC

UPDATE doesn't modify the row; it creates a new tuple version and marks the old one with xmax = current_xid (and possibly an H.O.T. chain). The old version is dead once no active snapshot can see it — but it occupies its 8KB page slot, full size, indefinitely. A table hammered with updates accumulates dead tuples at write rate × vacuum lag. The heap grows; scans read garbage; the table silently quadruples.

Step 2 — the trigger

Autovacuum (a background daemon) wakes periodically (default autovacuum_naptime 60s) and checks each table against thresholds: autovacuum_vacuum_threshold (default 50) + autovacuum_vacuum_scale_factor (0.2 × table size) dead tuples. A 1M-row table triggers at ~200k dead tuples — meaning by default, vacuum runs when the table is already 20% dead. That's the designed operating point: running vacuum more often costs I/O; running it less costs bloat. (And yes — tables that live under UPDATE storms without reaching threshold is the classic "vacuum never runs" trap.)

Step 3 — the scan

A vacuum worker scans the heap (it can run concurrently with everything — readers and writers proceed; vacuum's locks are weak), and for each page checks every tuple: is it dead? A tuple is dead when its xmax is committed and no snapshot can still see it (checked against the global procarray — the oldest active snapshot, aka the horizon). Heap pages get read from the buffer pool, dead tuples are marked (HEAPTUPLE_DEAD), and the page is cleaned in place: dead tuple space becomes free space for future inserts, LP_DEAD item pointers are reused.

Step 4 — the visibility map

Each 8KB page has a bit in the visibility map: "all tuples visible to every snapshot" — i.e., no unvacuumed dead tuples. When vacuum cleans a page, it sets the bit. That bit is what index-only scans check: an index-only scan skips the heap fetch only when the visibility-map bit is set. Vacuum is therefore also an index scan enabler — a table that never gets vacuumed silently disables index-only scans.

Step 5 — the free space map

Freed space is recorded in the table's free space map (FSM), so future inserts and page splits land in vacuumed pages instead of appending new ones. This is why bloat, left alone, tends to grow: no vacuum → no FSM entries → inserts keep appending → table keeps growing. The FSM is the loop-closing mechanism.

Step 6 — the index side

Vacuum also walks each index, removing index entries pointing at dead tuples and, on b-trees, recycling pages freed by those deletions (the "btree vacuum" pass with its own page-recycling machinery). Note the asymmetry: a table can be perfectly vacuumed while its indexes are bloated — e.g. monotonically increasing keys with scattered deletes. That's the case where only REINDEX or VACUUM FULL truly helps.

Step 7 — freeze, or the wraparound apocalypse

Transaction IDs are 32-bit (~2 billion); a transaction older than autovacuum_freeze_max_age (200M) becomes vulnerable to wraparound: XID comparison breaks when old XIDs wrap past new ones. Vacuum's second job: freeze tuples — mark them xmin = FrozenTransactionId (no longer comparable, permanently visible). vacuum_freeze_table_age/freeze_min_age control aggressiveness. If vacuum never gets there, Postgres shuts down with database is not accepting commands to avoid wraparound data loss. That's the one vacuum failure that's an emergency.

What it costs

  • The bargain: vacuum spends (scan pages + write FSM/VM bits) so that every other operation doesn't pay dead-tuple taxes.
  • Background pacing: vacuum_cost_limit + vacuum_cost_delay throttle I/O — a full-throttle vacuum can compete with production traffic; the throttle is the dial.
  • Default tuning means: tables hover at ~20% dead before cleanup — that's the designed steady state, not a bug.
sql
-- the health check
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables WHERE relname = 'orders';
   relname | n_live_tup | n_dead_tup |       last_autovacuum
-----------+------------+------------+----------------------------
   orders  |    8123453 |     983512 | 2026-08-18 02:12:41.112+00

983k dead tuples on an 8M-row table: vacuum last ran 12 hours ago and the table crossed its threshold since. Not a crisis — but n_dead_tup trending up over days, while last_vacuum stays old, is the canary for the three failure modes: autovacuum starved of workers, long-running transactions pinning the horizon (the oldest snapshot prevents cleanup forever), or a table whose threshold is never reached because it grows. Bloat is a symptom; the diagnosis is always one of those three.