Skip to main content
RunBook Academy

PostgreSQLVIII · VACUUM, Autovacuum and WraparoundVacuum

Tuning autovacuum from evidence

Advanced⏱ ~35 minpsql

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

Not yet marked complete on this device.

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_tup sits persistently just below default_threshold. This is the case measured below. It is the most common one and the least obvious, because nothing is failing.
  • last_autovacuum is old relative to the churn rate.
  • autovacuum_count is zero on a table with heavy n_tup_upd. Check reloptions for autovacuum_enabled=false, and check the log for canceling autovacuum task.
  • n_mod_since_analyze is 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:

Configuration changeper-table settings applied to one of the two
$ 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:

TableComputationThreshold
churn_default50 + 0.20 × 500000100,050 dead tuples
churn_tuned1000 + 0.01 × 5000006,000 dead tuples
Read-only / Safedead tuples sampled every 30 seconds under the identical workload
$ psql -U postgres -At -c "SELECT n_dead_tup, autovacuum_count FROM pg_stat_user_tables WHERE relname LIKE 'churn_%'"   # sampled in a loop
elapsed    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        4

Watch the last four rows. The workload ended, and the two tables settled in different places.

Read-only / Safefinal state, ninety seconds after the workload stopped
$ 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.56

Identical 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 = false is not tuning. Monitor for it.
  • Verify with pgstattuple at least once; n_dead_tup is 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

  1. 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?

  2. 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?

  3. Q3. A table has heavy update traffic and autovacuum_count of zero over several weeks, with last_autovacuum null. What should be checked first?

  4. Q4. Which of these are reasonable per-table autovacuum settings on a large, high-churn table? Select all that apply.

  5. Q5. A table with autovacuum_enabled = false will still be vacuumed when it crosses autovacuum_freeze_max_age.

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