A busy table produces obsolete versions at a rate its maintenance must accommodate. Lowering an autovacuum trigger can make that table eligible sooner, but eligibility is not completion. A worker must still reach the table, acquire the required locks, and finish enough useful cleanup before the backlog grows again. Measure that whole cycle instead of treating one configuration value as a throughput guarantee.
When it breaks
The normal update/delete trigger combines a fixed threshold with a scale factor multiplied by the estimated table population. PostgreSQL 18 also applies a maximum threshold when enabled. Insert-triggered vacuum and age-driven freezing have their own reasons to run. Looking only at one dead-tuple ratio misses those paths, and interpreting every vacuum as proof that the update/delete threshold fired can misdiagnose a table.
Reducing the threshold does not remove old snapshot retention or create more I/O capacity. More workers and less cost delay can increase resource competition with foreground requests. A dead-tuple estimate near zero at every instant is neither a realistic universal target nor sufficient evidence of good performance. The useful target is sustainable cleanup with acceptable growth, latency, and maintenance cost.
Check it quickly
Use a disposable PostgreSQL 18 instance with autovacuum and statistics tracking enabled, available workers, and its normal one-minute database inspection interval. Run the script through a fresh psql -X -v ON_ERROR_STOP=1 connection. It waits up to 130 seconds for an actual automatic run; a timeout is a failed observation, not permission to claim success from a manual vacuum instead.
CREATE SCHEMA churn_demo;
CREATE TABLE churn_demo.jobs (id integer PRIMARY KEY,n integer)
WITH (autovacuum_enabled=false,
autovacuum_vacuum_threshold=50,
autovacuum_vacuum_scale_factor=0.2);
INSERT INTO churn_demo.jobs SELECT g,0 FROM generate_series(1,10000) AS g;
SELECT pg_stat_force_next_flush();
VACUUM (ANALYZE) churn_demo.jobs;
UPDATE churn_demo.jobs SET n=1 WHERE id<=1000;
SELECT pg_stat_force_next_flush();
SELECT n_live_tup,n_dead_tup,vacuum_count,autovacuum_count
FROM pg_stat_user_tables WHERE relid='churn_demo.jobs'::regclass;
SELECT 50+0.2*reltuples AS old_threshold,50+0.01*reltuples AS new_threshold
FROM pg_class WHERE oid='churn_demo.jobs'::regclass;
ALTER TABLE churn_demo.jobs SET
(autovacuum_enabled=true,autovacuum_vacuum_scale_factor=0.01);
DO $$
DECLARE deadline timestamptz := clock_timestamp()+interval '130 seconds';
BEGIN
LOOP
PERFORM pg_stat_clear_snapshot();
EXIT WHEN (SELECT autovacuum_count>0 FROM pg_stat_user_tables
WHERE relid='churn_demo.jobs'::regclass);
IF clock_timestamp()>deadline THEN
RAISE EXCEPTION 'no automatic vacuum observed within the experiment budget';
END IF;
PERFORM pg_sleep(0.25);
END LOOP;
END $$;
SELECT pg_stat_clear_snapshot();
SELECT n_dead_tup,last_autovacuum IS NOT NULL AS ran,autovacuum_count>0 AS counted
FROM pg_stat_user_tables WHERE relid='churn_demo.jobs'::regclass;
SELECT count(*),count(*) FILTER (WHERE n=1) FROM churn_demo.jobs;
DO $$ BEGIN
IF (SELECT count(*) FROM churn_demo.jobs)<>10000
OR (SELECT count(*) FROM churn_demo.jobs WHERE n=1)<>1000 THEN
RAISE EXCEPTION 'maintenance changed application data';
END IF;
END $$;
DROP SCHEMA churn_demo CASCADE;
The table initially disables only its own routine autovacuum so setup and the controlled update complete first. Manual VACUUM ANALYZE establishes statistics and resets the insertion backlog before one thousand rows are updated. The explicit statistics-flush calls keep pending write counters from appearing after the manual maintenance estimate; they are laboratory synchronization, not a production tuning recommendation.
Before enabling the worker, expect estimated live and dead counts of ten thousand and one thousand, a manual-vacuum count of one, and an automatic-vacuum count of zero. Exact application counts are checked separately. With the specified fixed threshold of fifty, the old scale factor gives 2,050 and the smaller factor gives 150. The maximum threshold does not bind for this small table.
One thousand obsolete versions exceed the new trigger. The script enables routine autovacuum and lowers only that table’s factor, then waits for its automatic counter to advance. The polling block clears cached statistics before checking, so it does not keep reading one old statistics snapshot. It performs no manual vacuum after enabling the worker.
The final statistics report zero estimated dead tuples, a populated automatic timestamp, and a positive automatic counter. Application data remain ten thousand rows, with exactly one thousand values updated to one. In the tested instance, the observed background run completed within the budget. The exact delay depends on scheduler timing and worker availability, so the script asserts the event and data rather than one elapsed duration.
Diagnose the limiting stage
If no automatic run appears, check effective table options, server autovacuum settings, statistics tracking, worker activity, and conflicting locks. A manual maintenance timestamp cannot stand in for last_autovacuum. If runs complete but obsolete versions remain, investigate long transactions and other visibility horizons. If removable versions accumulate faster than completed work, then examine capacity, cost throttling, and write patterns.
Changing the scale factor for a large hot table is often more targeted than changing every table’s settings. Record the old options, compare several representative workload periods, and watch file growth and application latency along with maintenance counters. Reset or revise the override when the workload changes. The demonstration deletes its entire fixture at the end; a real table needs its chosen settings maintained deliberately, and continued freezing protection even when its update traffic falls quiet.
Sources
- PostgreSQL 18 autovacuum configuration, thresholds, scale factors, worker limits, and inspection intervals, checked 2026-09-10.
- PostgreSQL 18 routine vacuuming, trigger formulas and distinct maintenance purposes, checked 2026-09-10.
- PostgreSQL 18 cumulative statistics, manual versus automatic counters and cached statistics, checked 2026-09-10.
- PostgreSQL 18 statistics function implementation, the flush helper used to synchronize fixture observations, checked 2026-09-10.