Vacuum & bloatPro · lab evidenceSource-grounded2 real lab transcripts

Autovacuum Internals: How Dead Tuples Are Reclaimed

Because MVCC leaves dead tuples behind (see the MVCC article), something must reclaim that space and, just as importantly, advance the cluster's frozen-XID horizon to prevent transaction-ID wraparound. Autovacuum is the background subsystem that does both. Its code spans src/backend/postmaster/autovacuum.c (scheduling) and src/backend/access/heap/vacuumlazy.c (the actual work).

Grounded in the source

Where this comes from in postgres/postgres

Each citation was checked against the REL_17_STABLE branch: real file path, symbol present, exact line number. Follow any link to read the code.

The one thing to understand first

Because MVCC leaves dead tuples behind (see the MVCC article), something must reclaim that space and, just as importantly, advance the cluster's frozen-XID horizon to prevent transaction-ID wraparound. Autovacuum is the background subsystem that does both. Its code spans src/backend/postmaster/autovacuum.c (scheduling) and src/backend/access/heap/vacuumlazy.c (the actual work).

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_naptime seconds and decides which databases need attention, prioritising those closest to wraparound.
  • A worker is forked per database (up to autovacuum_max_workers) to process the tables that exceed their thresholds.

The threshold formula

A table becomes eligible for autovacuum when its estimated dead tuples exceed:

Reference
SQL
threshold = autovacuum_vacuum_threshold
          + autovacuum_vacuum_scale_factor * reltuples

With 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:

Lab-verified · corrected
SQL
ALTER TABLE big_table SET (
    autovacuum_vacuum_scale_factor = 0.02,
    autovacuum_vacuum_threshold    = 10000);
Real captured output for this query is in the Pro lab notebook below.

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 xmax is 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.

Crucially, 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.

The 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.

Lab-verified
SQL
-- 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;
Real captured output for this query is in the Pro lab notebook below.

Cost-based throttling

To avoid I/O storms, vacuum accumulates a "cost" as it reads and dirties pages and sleeps when it exceeds autovacuum_vacuum_cost_limit. On modern SSD systems the defaults are often too conservative; raising the cost limit lets autovacuum keep up with high-churn tables.

Layer 3 — Watch it happen on your own database

Lab-verified
SQL
-- 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;
Real captured output for this query is in the Pro lab notebook below.

The first query is your wraparound early-warning system — anything whose xid_age approaches autovacuum_freeze_max_age (default 200M) is overdue. The second shows which tables are accumulating dead tuples faster than autovacuum clears them. The third lets you watch a live vacuum move through its scan, index, and cleanup phases.

Layer 4 — The levers this hands you

  • Per-table scale factors. The 20% default is wrong for large tables; set autovacuum_vacuum_scale_factor low (e.g. 0.02) with a fixed autovacuum_vacuum_threshold on big, churny tables.
  • Raise the cost limit on SSDs. Increase autovacuum_vacuum_cost_limit (or lower autovacuum_vacuum_cost_delay) so vacuum keeps pace with write volume.
  • More workers for many hot tables. autovacuum_max_workers bounds concurrency; raise it if many tables qualify at once.
  • Long transactions throttle vacuum globally by holding back the cutoff XID — fix the transaction, not vacuum.
  • More indexes = slower vacuum because of the per-index cleanup phase; drop unused indexes.

Layer 5 — What an Oracle DBA should expect vs what they get

Oracle has no direct equivalent because it never created the problem autovacuum solves:

  • No background "undo cleanup" to compare to. Oracle reclaims old row images from UNDO automatically and overwrites blocks in place, so there is no table-level vacuum at all. In PostgreSQL, vacuum is a first-class operational concern you must monitor and tune.
  • Transaction-ID wraparound is uniquely Postgres. Oracle's SCN is 64-bit and never wraps in practice; PostgreSQL's 32-bit XID does, which is why freezing exists and why an ignored wraparound warning can stop writes entirely. There is no Oracle drill for this.
  • "My table doesn't shrink after a big delete." Correct — lazy vacuum only makes space reusable inside the table, like Oracle's high-water-mark behaviour. Reclaiming OS space needs VACUUM FULL (an exclusive-lock rewrite) or pg_repack, analogous to an Oracle segment shrink/move.
  • The visibility map is the index-only-scan enabler. Oracle index-only access just needs the index; in Postgres an index-only scan also checks the all-visible bit, so keeping vacuum current directly affects whether index-only scans are allowed.

Key takeaway

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.

Interview lens

How this shows up in interviews

What an interviewer is actually testing, the same mechanism seen from three roles, and the gap between a shallow answer and one that shows real depth.

What the interviewer is testing

Whether you know autovacuum has two jobs — reclaim dead tuples AND advance the frozen-XID horizon to prevent wraparound — and how it's triggered and throttled.

Questions you should be able to answer

  • What two distinct problems does autovacuum solve?
  • What triggers an autovacuum run on a particular table?
  • Why can autovacuum 'fall behind', and what forces it to catch up?

Database engineer

You tune per-table scale_factor/threshold and cost delays, and you watch n_dead_tup and age(relfrozenxid) before wraparound forces an emergency vacuum.

Software engineer

You understand a long-open transaction makes vacuum unable to remove tuples still visible to its xmin — so your idle transaction causes someone else's bloat.

Feature builder

You design bulk-churn features with vacuum in mind, e.g. partition-and-drop instead of a mass DELETE.

Shallow answer

'Autovacuum cleans up dead rows.' Misses wraparound prevention and the oldest-xmin constraint entirely.

Answer that shows depth

Autovacuum (scheduling in autovacuum.c, work in vacuumlazy.c) both reclaims dead tuples so their space is reusable AND advances relfrozenxid to prevent transaction-ID wraparound. A table is auto-vacuumed when dead tuples exceed autovacuum_vacuum_threshold + scale_factor x reltuples; the work is throttled by cost-based delays so it doesn't saturate I/O. Crucially it can only remove tuples no snapshot can still see, so the cluster's oldest xmin — often a long-running or idle-in-transaction session — caps what any vacuum can reclaim, and nearing wraparound triggers an aggressive anti-wraparound vacuum that cannot be skipped.

Pro lab notebook

The captured run behind every query

Every SQL query above is real and the source citations are verified. Pro unlocks the captured psql output — each query re-run against a real PostgreSQL 17.10 lab, with its verbatim result.

Pro lab notebookTested, not just explained

2 real lab transcripts for this lesson

The explanation and the SQL above are free — so are the verified source citations. What Pro unlocks is the proof: every query in Autovacuum Internals: How Dead Tuples Are Reclaimed re-run against a real PostgreSQL 17.10 instance, with its verbatim psql output captured line for line.

What Pro unlocks here

  • Every query in this lesson re-run against a real PostgreSQL 17.10 lab
  • The verbatim psql output — page headers, row versions, plan nodes, catalog rows
  • The corrected form of any query the source lesson got wrong, re-captured
  • A copy-ready notebook you can replay yourself to confirm every claim
Unlock with Pro — $24.99/moor $199/yr — save ~$100

How this was verified

This explanation is anchored to PostgreSQL's source tree — the files linked above, on the REL_17_STABLE branch — and cross-checked against the official documentation for versions 15 through 18. Every SQL query shown was executed and its output captured in a throwaway PostgreSQL 17.10 lab; where a query needed correcting to run, that correction is noted inline.

Official documentation: §25.1 Routine Vacuuming