Autovacuum Internals: How Dead Tuples Are Reclaimed
PostgreSQL keeps old row versions until no relevant observer can need them.
Autovacuum schedules workers that make safe dead space reusable and freeze old transaction references before wraparound becomes dangerous. It cannot remove a version that is still needed, and it does not reset PostgreSQL's transaction counter.
Keep reuse and time safe
Vacuum follows two clocks.
One clock measures table churn. The other measures transaction age. Autovacuum schedules work before dead space or XID history becomes dangerous.
01 / Choose the trigger
A table qualifies for vacuum three ways.
Use the same one-million-row table and change only the pressure signal. PostgreSQL 17 checks dead tuples, insert volume, and wraparound safety.
The worker considers this table for ordinary vacuum because estimated dead tuples exceed the configured base plus scale factor.
The launcher chooses a database; the worker connects there and chooses qualifying tables and TOAST relations.
02 / Follow one pass
Reclamation is ordered for safety.
Play the simplified lifecycle. Real lazy vacuum may cycle through index and heap cleanup when its dead-item memory fills, but the dependency stays the same.
Phase 1
Scan, prune, and freeze heap pages.
03 / Meet the horizon
Dead does not always mean removable.
Change the oldest observer. VACUUM can reclaim a version only after no relevant snapshot or retention mechanism can still need it.
xmax = 820xmin = 820VACUUM leaves the old version in place. Check actual backend_xmin, prepared transactions, and replication slots, not every idle session pins the horizon.
Anti-wraparound vacuum is forced as relfrozenxid ages. Ordinary VACUUM normally reuses space internally and may truncate completely empty tail pages; VACUUM FULL rewrites for general compaction.
Remember: churn decides when work is useful; XID age decides when it is mandatory; the cleanup horizon decides what is safe.
04 / Prove it yourself
Read both clocks from the catalog.
The canonical lab pairs XID age with dead-tuple and autovacuum activity. It is rendered once here as the operational payoff.
Lab-verifiedOpen the psql proof
-- Tables closest to wraparound trouble
SELECT relname, age(relfrozenxid) AS xid_age
FROM pg_class
WHERE relkind = 'r'
ORDER BY xid_age DESC LIMIT 10;
-- Dead-tuple counts and last autovacuum time
SELECT relname, n_dead_tup, n_live_tup, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC LIMIT 10;
-- A vacuum in flight: which phase and how far along
SELECT * FROM pg_stat_progress_vacuum;relname | xid_age
-----------------------+---------
pg_attrdef | 425
pg_inherits | 425
pg_subscription | 425
pg_foreign_table | 425
pg_authid | 425
pg_constraint | 425
pg_type | 425
pg_statistic_ext_data | 425
pg_user_mapping | 425
pg_operator | 425
(10 rows)
relname | n_dead_tup | n_live_tup | last_autovacuum
-----------+------------+------------+-------------------------------
demo | 1 | 1 |
accounts | 0 | 5000 | 2026-07-23 10:56:19.095032+02
big_table | 0 | 50000 |
orders | 0 | 20000 |
(4 rows)
pid | datid | datname | relid | phase | heap_blks_total | heap_blks_scanned | heap_blks_vacuumed | index_vacuum_count | max_dead_tuple_bytes | dead_tuple_bytes | num_dead_item_ids | indexes_total | indexes_processed
-----+-------+---------+-------+-------+-----------------+-------------------+--------------------+--------------------+----------------------+------------------+-------------------+---------------+-------------------
(0 rows)Payoff: age(relfrozenxid) is the wraparound clock; n_dead_tup and last_autovacuum show table churn and recent worker activity. Neither alone proves why cleanup is blocked.
Source trailUnder the hood
postmaster/autovacuum.cMaintains per-database scheduling and prioritizes wraparound-danger databases.
postmaster/autovacuum.cConnects a worker to one database and gathers qualifying relations.
access/heap/vacuumlazy.cBuilds relation cutoffs and drives lazy heap/index cleanup.
access/heap/vacuumlazy.cScans, prunes, freezes, records dead TIDs, and coordinates cleanup cycles.
Source-level guardrails
- PostgreSQL 17 has an insert trigger too. Dead tuples and inserts-since-vacuum use independent ordinary thresholds; wraparound is a third forcing path. relation_needs_vacanalyze
- MultiXact age has its own safety limit. The same decision path can force vacuum from relminmxid age, not only relfrozenxid age. relation_needs_vacanalyze
- Pruning is not VACUUM alone. Ordinary heap access may prune opportunistically; VACUUM uses its own prune-and-freeze path. heap_page_prune_opt
- All-visible is not always all-frozen. Aggressive vacuum must visit pages that are all-visible but still need freezing. lazy_scan_heap
- Ordinary VACUUM can sometimes shrink a tail. Its main job is reuse, but completely empty tail pages may be truncated when locking permits. lazy_truncate_heap
Autovacuum is a scheduler around ordinary VACUUM and ANALYZE machinery. Safety comes from relation cutoffs and tuple classification, not from treating every dead estimate as immediately reclaimable.
What to remember
Autovacuum schedules per-database workers when a table's dead-tuple estimate crosses threshold + scale_factor × reltuples, then vacuumlazy.c scans the heap (skipping all-visible pages), cleans indexes, defragments pages, and freezes old XIDs. It reclaims space for reuse and defends against wraparound, it does not shrink files. Tune scale factors and cost limits per table, hunt long transactions, and watch age(relfrozenxid): a healthy autovacuum is the difference between a database that quietly keeps up and one that seizes.
Say it out loud
Close the page and explain this to someone who has not read it.
Can you state autovacuum's two jobs, dead-tuple reclaim and frozen-XID advance, and walk trigger math → cost throttle → oldest-xmin ceiling → wraparound force, without reducing it to "background cleanup"?
Then compare with a full answer
Autovacuum schedules ordinary VACUUM and ANALYZE work. In PostgreSQL 17, vacuum can qualify from dead-tuple pressure or a separate inserts-since-vacuum threshold; XID or MultiXact age can force anti-wraparound work. Lazy vacuum prunes and freezes heap pages, removes index references before making dead line pointers reusable, and updates free-space and visibility maps. Cleanup is limited by relation-appropriate horizons: inspect backend_xmin, prepared transactions, and replication slots rather than blaming any idle session. Ordinary VACUUM mainly makes space reusable and may truncate completely empty tail pages; VACUUM FULL rewrites for general compaction. Freezing removes old tuple-ID dependencies but does not reset PostgreSQL's counters.
It has to connect
- Two jobs: (1) reclaim dead tuple space for reuse, (2) freeze / advance relfrozenxid so XID wraparound cannot corrupt visibility.
- Ordinary vacuum triggers: dead tuples and, in PostgreSQL 17, inserts-since-vacuum use independent base-plus-scale thresholds. XID or MultiXact age can force anti-wraparound work.
- Work path: lazy vacuum (vacuumlazy / heap vacuum), not table rewrite; throttled by cost-based delay so it shares I/O with the workload.
- Cleanup uses relation-appropriate horizons. For ordinary relations the relevant snapshot horizon is database-scoped; prepared transactions, backend_xmin, slots, and shared-catalog rules can also hold work back.
- Wraparound: relfrozenxid or relminmxid age can force anti-wraparound vacuum. Freezing removes old tuple-ID dependencies; it does not reset the counters.
- Ops order: clear horizon pin first, then per-table scale/threshold and bloat evidence, VACUUM FULL is not step one.
Where it usually stops short
"Autovacuum cleans up dead rows." Half a job. Misses freeze/wraparound, trigger math, cost throttling, and the oldest-xmin ceiling, so the candidate cannot debug "vacuum runs but the table still grows."
Push further
- What two distinct problems does autovacuum solve, and what breaks if you only talk about dead rows?
- What makes autovacuum pick a given table, and what slows the workers once they run?
- Autovacuum "fell behind" on a busy table, what usually caps reclaim, and what eventually forces aggressive work?
Read the written reference8 sections · ~570 words · 3 runnable queriesOne thing firstLauncher and workersThreshold formulaWhat a vacuum pass actually doesVisibility mapFreezing and wraparoundCost-based throttlingMaking autovacuum keep up
One thing first
Autovacuum is not optional cleanup, it is the process that keeps your database able to accept writes. It does two jobs that look unrelated but share one engine: reclaiming dead-tuple space, and freezing old rows before the 32-bit XID counter wraps. Neglect either and you get runaway bloat or a cluster that refuses writes.
Launcher and workers
Autovacuum has two process types:
- The launcher wakes every
autovacuum_naptimeseconds and decides which databases need attention, prioritizing those closest to wraparound. - Each worker connects to one database and selects qualifying tables and TOAST relations there;
autovacuum_max_workerslimits concurrent workers across the cluster.
Threshold formula
A table becomes eligible for autovacuum when its estimated dead tuples exceed:
threshold = autovacuum_vacuum_threshold
+ autovacuum_vacuum_scale_factor * reltuplesWith defaults (threshold = 50, scale_factor = 0.2), a 1,000,000-row table is vacuumed after ~200,050 dead tuples. The dead-tuple estimate comes from the cumulative statistics system, updated by backends as they modify rows. The same shape of formula with analyze_* parameters governs autoanalyze.
The classic mistake on large tables: the 20% scale factor means a billion-row table waits for 200 million dead tuples before vacuuming, far too late. The fix is per-table tuning:
ALTER TABLE big_table SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 10000);What a vacuum pass actually does
lazy_scan_heap() in vacuumlazy.c runs in phases:
- Scan heap. Walk pages (skipping all-visible pages via the visibility map), collecting the line pointers of dead tuples whose
xmaxis older than the vacuum's cutoff XID. - Vacuum indexes. For each index, remove entries pointing at the dead tuples. This is why many indexes make vacuum slower.
- Vacuum heap. Turn the dead line pointers into reusable space and defragment the page.
- Update the free space map and visibility map.
And here's the catch: ordinary (lazy) vacuum does not return space to the operating system, it makes space reusable within the table. Only VACUUM FULL (which rewrites the table and takes an exclusive lock) shrinks the file.
Visibility map
Each table has a visibility map (visibilitymap.c): two bits per heap page. The all-visible bit lets index-only scans skip heap fetches and lets the next vacuum skip the page entirely. The all-frozen bit lets aggressive freeze passes skip pages. Keeping these bits set is a major reason vacuum gets cheaper over time on append-mostly tables.
Freezing and wraparound
XIDs are 32-bit and wrap around after ~4 billion. To stay safe, vacuum freezes old tuples, marking them as unconditionally visible, before their age reaches autovacuum_freeze_max_age. When any table crosses that age, an anti-wraparound autovacuum is forced even if the table is otherwise quiet, and it cannot be skipped. Ignoring the warnings leads PostgreSQL to eventually refuse new writes to protect data, a production-stopping event.
-- Tables closest to wraparound trouble
SELECT relname, age(relfrozenxid) AS xid_age
FROM pg_class
WHERE relkind = 'r'
ORDER BY xid_age DESC LIMIT 10;relname | xid_age
-----------------------+---------
pg_attrdef | 425
pg_inherits | 425
pg_subscription | 425
pg_foreign_table | 425
pg_authid | 425
pg_constraint | 425
pg_type | 425
pg_statistic_ext_data | 425
pg_user_mapping | 425
pg_operator | 425
(10 rows)Cost-based throttling
Vacuum accumulates cost as it reads and dirties pages, then sleeps when the configured limit is reached. If workers consistently fall behind, tune the limit or delay against measured I/O headroom; SSD storage alone is not evidence that a higher limit is safe.
Making autovacuum keep up
- Per-table thresholds. On a very large high-churn table, the defaults may permit more dead tuples than the workload can tolerate. Tune scale and base thresholds from measured churn and vacuum duration.
- Tune cost throttling with evidence. Raise the limit or lower the delay only when workers are behind and storage has measured headroom.
- Concurrency needs capacity. More workers can help when many databases or tables qualify, but only if CPU, memory, and I/O can support them.
- Old horizons cap cleanup. Inspect actual
backend_xmin, prepared transactions, and replication slots. Ordinary relation cleanup is database-scoped; shared catalogs use a cluster-wide horizon. - More indexes = slower vacuum because of the per-index cleanup phase; drop unused indexes.
Check it against the source2 citations in postgres/postgres · file, symbol and line verified on REL_17_STABLE
Primary symbol: heap_vacuum_rel · line 295
Primary symbol: do_autovacuum · line 1877
Anchored to postgres/postgres on REL_17_STABLE and cross-checked against the manual for PostgreSQL 15–18. Every query here was run and its output captured on a throwaway PostgreSQL 17.10 lab; corrections are noted inline. §25.1 Routine Vacuuming in the official docs →
Connected
Where this lesson sits
What comes before, after, and alongside it.
Part of these pathways
Same learning track
Finished a free lesson?
Pro opens the rest of the engine course
You felt one mechanism. Pro is the full bodies, interview depth, and the tracks that build on this session.