Autovacuum Thresholds and Dead Tuple Drift #
Autovacuum’s defaults were chosen when a large table meant a few million rows. Applied unchanged to a table with hundreds of millions, they produce a daemon that waits patiently while tens of millions of dead tuples accumulate — inflating relation sizes, degrading index-only scans, and letting statistics drift far from reality before a single pass runs.
This page shows how to compute what your current settings actually mean for each table, and how to set thresholds that behave sensibly at scale. The plan-level consequences are covered in autovacuum and plan stability.
The Formula, Applied to Real Tables #
Both vacuum and analyze use the same shape:
vacuum threshold = autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × reltuples
analyze threshold = autovacuum_analyze_threshold + autovacuum_analyze_scale_factor × reltuples
Defaults are 50 / 0.2 for vacuum and 50 / 0.1 for analyze. Because the scale factor multiplies the row count, the absolute threshold grows without limit:
| rows | vacuum triggers after | analyze triggers after |
|---|---|---|
| 10,000 | 2,050 dead tuples | 1,050 modifications |
| 1,000,000 | 200,050 | 100,050 |
| 100,000,000 | 20,000,050 | 10,000,050 |
| 500,000,000 | 100,000,050 | 50,000,050 |
The last row is the problem. Half a billion rows means the planner tolerates 50 million modifications before refreshing its picture of the table — by which time the most-common-value list may describe a distribution that no longer exists.
Reading the Drift #
pg_stat_user_tables holds everything needed to judge the current state:
SELECT relname,
n_live_tup,
n_dead_tup,
round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
n_mod_since_analyze,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 100000
ORDER BY n_dead_tup DESC;
Three patterns matter:
dead_pctabove 20% with a recentlast_autovacuum— the daemon is running but not keeping up. Raise the cost limit, not the frequency.dead_pctabove 20% with an oldlast_autovacuum— the threshold is too high. Lower the scale factor for that table.n_mod_since_analyzelarge whiledead_pctis near zero — an insert-heavy table. Vacuum has nothing to reclaim, but statistics are drifting badly, so the analyze scale factor is what needs lowering.
The same view tells you whether cleanup is blocked rather than slow:
SELECT pid, state, xact_start, now() - xact_start AS age, left(query, 60) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 5;
A transaction open for hours pins the oldest snapshot, and no amount of autovacuum tuning will reclaim tuples newer than it.
Step-by-Step Tuning #
-
Rank tables by absolute dead tuples, not percentage — the biggest tables cause the biggest plan damage.
-
Compute the effective threshold for each candidate so the numbers are concrete:
SELECT relname, n_live_tup, 50 + 0.2 * n_live_tup AS vacuum_threshold_now, 50 + 0.02 * n_live_tup AS vacuum_threshold_tuned FROM pg_stat_user_tables WHERE relname = 'events'; -
Set per-table storage parameters on the tables that need them:
ALTER TABLE events SET ( autovacuum_vacuum_scale_factor = 0.02, autovacuum_analyze_scale_factor = 0.01, autovacuum_vacuum_cost_limit = 2000 ); -
Raise the global cost limit if workers are the bottleneck. The default
autovacuum_vacuum_cost_limitof 200 throttles a worker to a fraction of what modern storage can do:ALTER SYSTEM SET autovacuum_vacuum_cost_limit = 2000; SELECT pg_reload_conf(); -
Add workers only alongside the cost limit. The budget is shared, so more workers with the same limit means each runs slower.
-
Watch a cycle complete rather than assuming it did:
SELECT p.relid::regclass, p.phase, p.heap_blks_scanned, p.heap_blks_total FROM pg_stat_progress_vacuum p; -
Guard against snapshot holders. Set
idle_in_transaction_session_timeoutand monitor replication slot lag, both of which silently prevent reclamation.
Before and After #
-- BEFORE: defaults on a 480M-row table
n_dead_tup : 61,204,882 dead_pct: 11.3
last_autovacuum : 3 days ago
index scan for 412 rows: Buffers shared hit=31 read=27 Execution Time: 11.9 ms
-- AFTER: scale_factor 0.02, cost_limit 2000
n_dead_tup : 3,918,204 dead_pct: 0.8
last_autovacuum : 41 minutes ago
index scan for 412 rows: Buffers shared hit=9 read=2 Execution Time: 1.6 ms
The queries were untouched. Cutting the dead-tuple population by 94% removed the pages that had been padding every scan.
Common Pitfalls #
Lowering the scale factor globally. Every small table then gets vacuumed constantly, consuming worker slots the big tables need. Diagnostic signal: workers permanently busy on trivial tables. Fix: set the parameter per table.
Adding workers without raising the cost limit. The budget is shared, so each worker simply gets a smaller slice. Diagnostic signal: more workers, same total throughput. Fix: raise autovacuum_vacuum_cost_limit in step.
Tuning vacuum when the problem is analyze. An insert-only table accumulates no dead tuples but drifts statistically. Diagnostic signal: n_mod_since_analyze high, n_dead_tup near zero. Fix: lower autovacuum_analyze_scale_factor.
Ignoring snapshot holders. Long transactions, abandoned idle in transaction sessions, unused replication slots, and prepared transactions all block reclamation regardless of settings. Diagnostic signal: dead tuples rising despite a recent successful vacuum. Fix: find and clear the holder, then set timeouts.
Trusting n_dead_tup as an exact figure. It is an estimate maintained by the statistics collector and updated as backends report activity, so it lags reality and resets when statistics are reset. Diagnostic signal: a dead-tuple count that disagrees with pgstattuple on the same table. Fix: use the counter for ranking and alerting, and confirm with a direct measurement before a costly intervention.
Setting autovacuum_vacuum_threshold instead of the scale factor. The fixed component is dwarfed by the scaled one on any large table, so raising it from 50 to 5,000 changes nothing meaningful. Diagnostic signal: a tuning change with no observable effect on cycle frequency. Fix: adjust the scale factor, which is the term that actually scales.
Disabling autovacuum on a busy table. Turning it off to avoid interference guarantees transaction-ID wraparound protection eventually kicks in with an aggressive, unavoidable freeze pass at the worst possible moment. Diagnostic signal: autovacuum_enabled = false in a table’s reloptions. Fix: tune the cost limit so the daemon is unobtrusive rather than switching it off, and never rely on a nightly cron job as the only cleanup.
Ignoring insert-only tables before PostgreSQL 13. Older versions triggered vacuum only on dead tuples, so an append-only table was never vacuumed and its visibility map never advanced. Diagnostic signal: index-only scans with rising Heap Fetches on a table that is never updated. Fix: schedule periodic vacuums explicitly, or rely on the insert-based threshold on modern versions.
Frequently Asked Questions #
What scale factor should a large table use? Roughly 0.01–0.02, set per table. Think in absolute terms: on a 100-million-row table a scale factor of 0.01 still allows a million dead tuples before a cycle begins, which is usually the most you want to tolerate.
Why does n_dead_tup keep rising even though autovacuum ran? Because something is holding an old snapshot. Vacuum cannot remove tuples that a transaction older than their deletion might still need, so a long-running query, an idle-in-transaction session, a stale replication slot, or a prepared transaction freezes reclamation.
Is raising autovacuum_max_workers enough?
Usually not. The I/O cost budget is shared across workers, so more workers each move more slowly. Raise autovacuum_vacuum_cost_limit at the same time, or set a per-table cost limit on the tables that must finish promptly.
Related #
- Autovacuum and Plan Stability — parent guide: how these settings reach the planner
- Stale Statistics After Bulk Loads — sibling: the acute version of the same drift
- Runtime Environment — section overview: the operational layer around query execution
- Index Maintenance and Bloat — what the same dead tuples do to index pages