Overview
Vacuum reclaims dead tuples and refreshes planner statistics. Every UPDATE and DELETE leaves a dead tuple behind under multi-version concurrency control, and autovacuum clears them on a schedule. The defaults suit small tables and fall behind on hot ones. The umbrella rules live in postgres and the production playbook in postgres-prod.
Tune autovacuum per table on hot writes
Autovacuum starts when dead tuples exceed autovacuum_vacuum_threshold (50) plus autovacuum_vacuum_scale_factor (0.2) times the row count. Postgres 18 caps that trigger at autovacuum_vacuum_max_threshold (100 million dead tuples), so a billion-row table no longer waits for 200 million. Lower the scale factor on high-churn tables.
ALTER TABLE events SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_analyze_scale_factor = 0.01
);Confirm with pg_stat_user_tables.last_autovacuum that the table is vacuumed often enough. Run VACUUM (ANALYZE) by hand after a bulk load or large delete instead of waiting for the thresholds.
Raise the cost budget before adding workers
Autovacuum throttles itself with a cost budget: autovacuum_vacuum_cost_limit defaults to -1, which uses vacuum_cost_limit (200), with autovacuum_vacuum_cost_delay at 2 ms. The budget is shared across running workers, so adding workers alone does not speed anything up.
ALTER SYSTEM SET autovacuum_vacuum_cost_limit = 2000;
ALTER SYSTEM SET autovacuum_max_workers = 6;
SELECT pg_reload_conf();On Postgres 18, autovacuum_max_workers changes with a reload, up to autovacuum_worker_slots (default 16, restart to change). Before 18, changing it needs a restart. Watch pg_stat_progress_vacuum; a vacuum that takes longer than the table’s churn interval falls behind permanently. Postgres 18 also lets normal vacuums freeze some all-visible pages early (vacuum_max_eager_freeze_failure_rate, default 0.03), which cuts later anti-wraparound work.
Detect bloat from dead tuples
SELECT relname, n_live_tup, n_dead_tup,
round(n_dead_tup::numeric / nullif(n_live_tup, 0), 3) AS dead_ratio,
last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY dead_ratio DESC NULLS LAST;A dead ratio near or above the 0.2 default scale factor means autovacuum is not keeping up. Use the pgstattuple extension for accurate table and index bloat, and export the metric to observability so on-call sees a dashboard instead of running ad-hoc queries.
Avoid VACUUM FULL in production
VACUUM FULL rewrites the table and holds an ACCESS EXCLUSIVE lock, blocking reads and writes for the whole rewrite. On a 200 GB table that is an outage. Routine bloat needs tuned autovacuum, not a rewrite. Use TRUNCATE when the rows are not needed.
Use pg_repack for online cleanup
pg_repack rewrites a bloated table or its indexes online: it copies into a shadow table, captures concurrent changes with a trigger, and swaps under a brief lock. The table needs a primary key or a unique index on NOT NULL columns, and the rewrite needs free disk roughly equal to the table size.
pg_repack -h db.internal -U postgres -d app_prod -t events
pg_repack -h db.internal -U postgres -d app_prod --only-indexes -t eventsRun it at low traffic and watch replication lag; see postgres-replication.
Prevent transaction ID wraparound
Transaction IDs are 32-bit. Tables must be vacuumed within about 2 billion transactions, and the server stops accepting commands when fewer than 3 million remain. Autovacuum forces an anti-wraparound vacuum once a table’s unfrozen age passes autovacuum_freeze_max_age (default 200 million).
SELECT datname, age(datfrozenxid) AS xid_age
FROM pg_database ORDER BY age(datfrozenxid) DESC;Alert while the age is still far from the limit; an age that keeps climbing past autovacuum_freeze_max_age means vacuum is blocked or failing. The usual blockers are long-running transactions, abandoned replication slots, and orphaned prepared transactions. Monitor multixact age the same way.
Partition cold history
Vacuum cost grows with table size, not churn. A terabyte append-only table vacuums slowly even when only the last day changes. Range-partition by time so old partitions stay static, and drop the oldest with DROP TABLE instead of deleting rows; see postgres-partitioning.