PostgreSQLVIII · VACUUM, Autovacuum and WraparoundVacuum
Tuning autovacuum from evidence
What you'll learn
- Decide from measurement whether a table needs per-table autovacuum settings
- Choose values that follow from the workload rather than from a blog post
- Recognise the permanent dead-tuple backlog the default scale factor produces
- Verify that a change had the effect you intended
Prerequisites
Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27
Autovacuum tuning advice is abundant and mostly untethered. This lesson tunes one table by measurement, shows what the change was worth, and states what the measurement does and does not establish.
Deciding whether a table needs anything
Most tables need nothing. Start by finding the ones that do.
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,
round(50 + 0.2 * n_live_tup) AS default_threshold,
autovacuum_count,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables
WHERE n_live_tup > 10000
ORDER BY n_dead_tup DESC
LIMIT 20;
The signals worth acting on:
n_dead_tupsits persistently just belowdefault_threshold. This is the case measured below. It is the most common one and the least obvious, because nothing is failing.last_autovacuumis old relative to the churn rate.autovacuum_countis zero on a table with heavyn_tup_upd. Checkreloptionsforautovacuum_enabled=false, and check the log forcanceling autovacuum task.n_mod_since_analyzeis large. A statistics problem, covered in lesson VIII-07.
The measurement
Two tables, 500,000 rows each, identical definitions. An identical workload was applied to both at the same time: 2,000 rows updated per round, one round roughly every two seconds, sustained for five minutes.
The only difference:
$ psql -U postgres -c "ALTER TABLE churn_tuned SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_threshold = 1000, autovacuum_vacuum_cost_delay = 0, autovacuum_analyze_scale_factor = 0.02)" -c "SELECT relname, reloptions FROM pg_class WHERE relname LIKE 'churn_%' ORDER BY relname" relname | reloptions
---------------+---------------------------------------------------
churn_default |
churn_tuned | {autovacuum_vacuum_scale_factor=0.01,
autovacuum_vacuum_threshold=1000,
autovacuum_vacuum_cost_delay=0,
autovacuum_analyze_scale_factor=0.02}
(2 rows)Effective thresholds:
| Table | Computation | Threshold |
|---|---|---|
churn_default | 50 + 0.20 × 500000 | 100,050 dead tuples |
churn_tuned | 1000 + 0.01 × 500000 | 6,000 dead tuples |
$ psql -U postgres -At -c "SELECT n_dead_tup, autovacuum_count FROM pg_stat_user_tables WHERE relname LIKE 'churn_%'" # sampled in a loopelapsed default_dead tuned_dead def_av tun_av
30s 58000 50000 0 1
60s 114000 106000 0 1
90s 50000 48000 1 2
120s 106000 104000 1 2
150s 50000 48000 2 3
180s 68000 66000 2 3
210s 68000 0 2 4
240s 68000 0 2 4
270s 68000 0 2 4
300s 68000 0 2 4Watch the last four rows. The workload ended, and the two tables settled in different places.
$ psql -U postgres -x -c "SELECT * FROM pgstattuple('churn_default')" -x -c "SELECT * FROM pgstattuple('churn_tuned')"churn_default:
tuple_percent | 52.35
dead_tuple_count | 67206
dead_tuple_percent | 7.04
free_percent | 37.16
churn_tuned:
tuple_percent | 52.35
dead_tuple_count | 0
dead_tuple_percent | 0
free_percent | 44.56Identical live data — tuple_percent is 52.35 on both. The default
table is carrying 67,206 dead tuples that nothing is coming for,
because 68,000 is below its threshold of 100,050.
Choosing values
The settings, and how to reason about each.
autovacuum_vacuum_scale_factor. The one that matters. Lower it on
large, high-churn tables. 0.01 to 0.05 is a normal range; the value
should follow from how much dead space you are willing to carry, which
is a capacity decision rather than a performance one.
autovacuum_vacuum_threshold. Raise it alongside a lowered scale
factor so that a table which is momentarily small does not get vacuumed
constantly. The pair (1000, 0.01) means “1,000 dead tuples plus 1% of
the table”.
autovacuum_vacuum_cost_delay = 0. Removes throttling for this
table’s autovacuums. Justified when the table genuinely must be kept
clean and you have measured that the storage can absorb it. It is not a
free win: the worker will read and dirty pages as fast as it can, and
that competes with your workload.
autovacuum_analyze_scale_factor. Separate trigger, separate
decision. A table whose distribution changes fast needs frequent
analyze even if it has few dead tuples — an append-only table with a
timestamp column that queries filter on is the classic case.
autovacuum_vacuum_insert_scale_factor. For append-only tables, to
control how often they are vacuumed for visibility map and freezing
purposes.
fillfactor. Not an autovacuum setting, but part of the same loop,
as lesson VII-02 measured. Consider it together with these.
Verifying the change
A tuning change that is not verified is a guess with extra steps.
-- 1. Confirm the setting is actually in force
SELECT relname, reloptions FROM pg_class WHERE relname = 'orders';
-- 2. Reset the counters so the comparison is clean
SELECT pg_stat_reset_single_table_counters('orders'::regclass);
-- 3. After a representative period, compare against the prediction
SELECT relname, n_dead_tup, autovacuum_count, last_autovacuum,
round(1000 + 0.01 * n_live_tup) AS new_threshold
FROM pg_stat_user_tables WHERE relname = 'orders';
-- 4. And measure the thing you were actually trying to fix
SELECT * FROM pgstattuple('orders');
Step 4 matters because n_dead_tup is an estimate maintained by the
statistics collector, while pgstattuple reads the relation. If a
tuning change is worth making it is worth confirming with the exact
number at least once.
What to take from this
- Most tables need no per-table settings. Find the ones that do from
pg_stat_user_tables. - The default scale factor leaves a standing dead-tuple population proportional to table size: 67,206 tuples, 7.04% of the table, in the measurement here.
(threshold, scale_factor)is a pair. Lower the factor, raise the threshold.autovacuum_enabled = falseis not tuning. Monitor for it.- Verify with
pgstattupleat least once;n_dead_tupis an estimate. - If a table is vacuumed constantly and never gets clean, thresholds are not the problem.
Cross-course references
- Ansible for Production Sysadmins — Part XVII (Templates) covers generating per-table storage parameters from an inventory rather than applying them by hand, and Part XXXVI (Drift) covers detecting a setting somebody changed in an incident and never reverted.
- Observability for Production Sysadmins — Part LIX (Database observability) covers the before-and-after series that is the only evidence a tuning change worked.
Quiz
Knowledge check · 6 questions
Q1. A 500,000-row table on default settings shows n_dead_tup steady at 68,000 for an hour with no autovacuum running. Is this a fault?
Q2. An engineer lowers autovacuum_vacuum_scale_factor to 0.01 on a table whose vacuum has been reporting '0 removed, 4,000,000 are dead but not yet removable'. What will change?
Q3. A table has heavy update traffic and autovacuum_count of zero over several weeks, with last_autovacuum null. What should be checked first?
Q4. Which of these are reasonable per-table autovacuum settings on a large, high-churn table? Select all that apply.
Q5. A table with autovacuum_enabled = false will still be vacuumed when it crosses autovacuum_freeze_max_age.
Q6. You have lowered the scale factor on a busy table. Describe how you would verify the change did what you intended.
Passing score: 75%. Answers are checked in this browser.