PostgreSQL autovacuum tuning becomes urgent the moment its symptoms surface: tables that keep growing while row counts stay flat, index scans that read twice the pages they did last quarter, queries slowing down uniformly across a table, and — in the worst case — the
database is not accepting commands to avoid wraparound data loss emergency. All of these trace back to one mechanism: dead tuples being created faster than autovacuum reclaims them. This guide covers the diagnosis and the fix as we apply it in production PostgreSQL 13–17 fleets: measure the lag, find why workers can't keep up, tune per-table rather than globally, and verify with the catalog views that exposed the problem.
Treat PostgreSQL autovacuum tuning as a workflow rather than a list of knobs, and keep it anchored to documented behaviour in the PostgreSQL manual chapters on
routine vacuuming and
autovacuum configuration parameters.
PostgreSQL Autovacuum Tuning: Key Takeaways
- Confirm the symptom before changing anything —
n_dead_tup, dead_pct and last_autovacuum from pg_stat_user_tables tell you whether autovacuum is genuinely behind.
- Most PostgreSQL autovacuum tuning wins come from per-table storage parameters, not from global defaults that were designed for far smaller tables.
- Cost-based throttling, not trigger thresholds, is the usual reason workers cannot keep up on large, write-heavy tables.
- Long transactions, abandoned replication slots, stale prepared transactions and standby feedback hold the horizon open and defeat even perfect settings.
- Treat transaction-ID age as a first-class alert, because PostgreSQL autovacuum tuning is also wraparound insurance.
- Verify every change over a full business cycle with the same catalog views that surfaced the problem.
Symptom Confirmation: Is Autovacuum Actually Behind?
PostgreSQL autovacuum tuning starts with dead-tuple accounting per table, not with guesses:
SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
last_autovacuum,
last_autoanalyze,
autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
Healthy OLTP tables sit under ~5% dead tuples between vacuum cycles. A table showing 25% dead with
last_autovacuum hours or days old is confirmed lag. Cross-check physical bloat — the statistics estimate dead tuples, but bloat is about pages that vacuum marked reusable yet the table never shrinks.
pgstattuple gives ground truth (it reads the whole table; run in off-peak windows):
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstattuple('public.orders');
-- free_percent + dead_tuple_percent > 20-30% on a large table = material bloat
Root Cause 1: Thresholds Scaled for Small Tables
The defaults —
autovacuum_vacuum_scale_factor = 0.2,
autovacuum_vacuum_threshold = 50 — mean a table qualifies for vacuum after 20% of it is dead. On a 500-million-row table, that's 100 million dead tuples before autovacuum even starts. The fix is per-table storage parameters, not a global change that would over-vacuum small tables:
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_threshold = 10000,
autovacuum_analyze_scale_factor = 0.005
);
This PostgreSQL autovacuum tuning change takes effect immediately, requires no reload, and the rollback path is
ALTER TABLE orders RESET (autovacuum_vacuum_scale_factor, ...). Apply it to the top 10–20 tables from the dead-tuple query above; leave the global defaults alone.
Root Cause 2: Cost-Based Throttling Starves the Workers
Autovacuum frequently is running — just too slowly to matter, which is where PostgreSQL autovacuum tuning shifts from thresholds to throughput. The cost limiter pauses workers after a budget of page accesses. Check the effective settings:
SELECT name, setting, unit, context
FROM pg_settings
WHERE name IN (
'autovacuum_vacuum_cost_limit',
'autovacuum_vacuum_cost_delay',
'autovacuum_max_workers',
'autovacuum_naptime',
'maintenance_work_mem',
'autovacuum_work_mem'
);
The PostgreSQL autovacuum tuning changes we most often make, with current-vs-proposed values:
| Parameter |
Default |
Proposed (NVMe-class storage) |
Unit |
Apply |
| autovacuum_vacuum_cost_limit |
200 (via vacuum_cost_limit) |
2000–4000 |
cost units |
reload |
| autovacuum_vacuum_cost_delay |
2 |
1–2 (keep low) |
ms |
reload |
| autovacuum_max_workers |
3 |
6 (many large tables) |
workers |
restart |
| autovacuum_work_mem |
-1 (uses maintenance_work_mem) |
512MB–1GB |
memory |
reload |
Two version-sensitive PostgreSQL autovacuum tuning notes. First, the cost budget is shared across all workers — raising
autovacuum_max_workers without raising
autovacuum_vacuum_cost_limit makes each worker slower, a classic mis-tune. Second, on PostgreSQL 17+ the dead-tuple store (TID store) no longer has the old 1 GB practical ceiling and memory behavior improved substantially; on 13–16, an undersized
autovacuum_work_mem forces multiple index-scan passes per vacuum — visible as multiple
index scans: N (N>1) in the autovacuum log line, and each pass rescans every index in full.
Turn on the log your PostgreSQL autovacuum tuning work needs:
log_autovacuum_min_duration = 0 (reload) during the investigation window, then read the elapsed time, pages removed, and index scan counts per run.
Root Cause 3: Something Is Holding Back the Horizon
No amount of PostgreSQL autovacuum tuning helps here: vacuum can only remove tuples invisible to every transaction. Four horizon-holders account for nearly all "autovacuum runs but n_dead_tup never drops" cases:
-- Long-running transactions
SELECT pid, NOW() - xact_start AS xact_age, state, LEFT(query, 60) AS query_head
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 10;
-- Abandoned replication slots (the silent killer)
SELECT slot_name, active, restart_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;
-- Orphaned prepared transactions
SELECT gid, prepared, owner FROM pg_prepared_xacts;
Add
hot_standby_feedback = on standbys running week-long analytics transactions to the suspect list. The fixes are procedural: idle-in-transaction timeouts (
idle_in_transaction_session_timeout = '10min', reload, coordinate with application owners first), dropping abandoned slots (verify the consumer is truly decommissioned —
pg_drop_replication_slot() is irreversible for that slot's position), and resolving prepared transactions with the application team that owns the XA coordinator.
Recovering Existing Bloat After PostgreSQL Autovacuum Tuning

PostgreSQL autovacuum tuning stops new bloat; it does not shrink a table that is already 40% air. Plain
VACUUM returns space to the table's free space map, not to the OS. Options, in order of preference for production:
pg_repack rebuilds the table and its indexes online with only brief locks at swap time. It requires the extension, a primary key or unique index, and free disk equal to the new table size. This is our default for 24×7 systems.
VACUUM FULL takes an
ACCESS EXCLUSIVE lock for the entire rebuild — a full outage for that table. Acceptable only inside a declared maintenance window with a verified backup, never as a reflex. Gate it explicitly in your runbook: verify row count and size before, confirm application drain, run, then validate row count matches and size dropped after.
The Wraparound Dimension of PostgreSQL Autovacuum Tuning
Autovacuum lag is also transaction-ID hygiene, and PostgreSQL autovacuum tuning has to account for it. Track the oldest table and database ages:
SELECT datname, age(datfrozenxid) AS xid_age
FROM pg_database
ORDER BY xid_age DESC;
Autovacuum forces aggressive anti-wraparound vacuums at
autovacuum_freeze_max_age (default 200 million). If your busiest tables'
age(relfrozenxid) trends toward that ceiling faster than vacuums complete, the throughput fixes above are not optional — they are wraparound prevention. Alert at 50% of the ceiling; a database that reaches the hard limit at ~2 billion stops accepting writes until a single-user-mode vacuum completes, which on a multi-TB system is measured in hours of downtime.
Verification: Confirming the PostgreSQL Autovacuum Tuning Fix
After PostgreSQL autovacuum tuning changes, verify with the same views over a full business cycle:
n_dead_tup on the tuned tables should oscillate in a narrow band and reset after each
last_autovacuum;
log_autovacuum_min_duration output should show single index-scan passes and elapsed times in minutes, not hours;
pgstattuple free space on repacked tables should be under ~10%; and
age(datfrozenxid) should be flat or declining. If dead tuples still climb, return to the horizon-holder checks — something upstream of vacuum is the cause.
Insert-Only Tables: The Blind Spot Fixed in PostgreSQL 13
Append-only tables — event logs, time-series, audit trails — generate almost no dead tuples, so on PostgreSQL 12 and earlier they could go months without an autovacuum, until the anti-wraparound deadline forced a monster aggressive vacuum at the worst possible time, reading terabytes of never-vacuumed pages in one throttled pass. PostgreSQL 13 added insert-driven autovacuum:
autovacuum_vacuum_insert_threshold (default 1000) and
autovacuum_vacuum_insert_scale_factor (default 0.2) trigger proactive vacuums that set visibility-map bits and, on 13+, freeze incrementally.
Two production implications shape PostgreSQL autovacuum tuning on append-only tables. First, tune it downward on very large append tables so freezing stays incremental —
ALTER TABLE events SET (autovacuum_vacuum_insert_scale_factor = 0.02); — cheap vacuums over mostly-all-visible pages beat one giant deadline vacuum every time. Second, the same visibility-map maintenance is what keeps index-only scans actually index-only: a table whose
pg_visibility_map coverage decays forces heap fetches on every "index-only" scan, a quiet latency regression that shows up as rising
Heap Fetches: in EXPLAIN ANALYZE. Check coverage directly:
SELECT relname,
ROUND(100.0 * relallvisible / NULLIF(relpages, 0), 1) AS all_visible_pct
FROM pg_class
WHERE relkind = 'r'
ORDER BY relpages DESC
LIMIT 15;
TOAST Tables: The Bloat You Forgot to Look At
PostgreSQL autovacuum tuning has an easy blind spot here: every table with large text/jsonb/bytea values has a shadow TOAST table, and it bloats independently. A jsonb-heavy table that looks healthy in
pg_stat_user_tables can hide a TOAST table twice its size, because updated documents rewrite whole TOAST chunks. Surface the split:
SELECT
c.relname,
pg_size_pretty(pg_table_size(c.oid)) AS total_incl_toast,
pg_size_pretty(pg_relation_size(c.oid)) AS heap_only,
pg_size_pretty(COALESCE(pg_relation_size(c.reltoastrelid), 0)) AS toast_size
FROM pg_class AS c
WHERE c.relkind = 'r'
ORDER BY pg_table_size(c.oid) DESC
LIMIT 15;
TOAST tables inherit the main table's autovacuum storage parameters only partially — they can be tuned independently via
ALTER TABLE ... SET (toast.autovacuum_vacuum_scale_factor = 0.01), and on document-churn workloads that override is frequently the missing fix. If a TOAST table is already grossly bloated, pg_repack on the parent rebuilds both. Structurally, applications that update one small field inside a 200 KB jsonb document pay full-document TOAST rewrite every time; splitting hot scalar fields out of the document into ordinary columns cuts both bloat generation and WAL volume — measure
n_tup_upd against TOAST growth to make the case.
Monitoring: PostgreSQL Autovacuum Tuning That Catches Lag Before Users Do

Reactive dead-tuple queries find yesterday's problem. A standing autovacuum observability layer needs four signals — and the last two are where PostgreSQL autovacuum tuning usually goes blind:
- Progress:
pg_stat_progress_vacuum shows phase, heap blocks scanned, and — critically — index_vacuum_count, the live counter of index-scan passes; a vacuum on its third pass is telling you autovacuum_work_mem is undersized right now.
- Debt trend: export
n_dead_tup and dead_pct per table (postgres_exporter ships this) and alert on slope, not absolute value — a table adding a million dead tuples per hour with a daily vacuum cadence is an incident scheduled for next week.
- Age pressure: alert at
age(datfrozenxid) > 100M (50% of the forced-vacuum ceiling) and page at 150M; also monitor mxid_age(datminmxid) — multixact wraparound is the same failure wearing a mask, driven by foreign keys and SELECT ... FOR SHARE, and it is routinely unmonitored.
- Efficacy: from the
log_autovacuum_min_duration lines, track elapsed time per table per run and pages removed; rising duration at constant table size means throttling or horizon interference, and single-line evidence beats an hour of live debugging during the eventual page.
Wire these PostgreSQL autovacuum tuning signals into the same alert routing as replication lag and disk space — vacuum debt is an availability signal, not a housekeeping curiosity. Our remote DBA runbooks treat a wraparound-age page identically to a failed-backup page: response measured in hours, with a pre-approved escalation to manual parallel
VACUUM (FREEZE) per table when the deadline math demands it. PostgreSQL 17's
vacuum_buffer_usage_limit (raised default and configurable ring buffer, arriving in 16) is also worth setting deliberately: the larger ring lets emergency vacuums move faster without evicting the entire buffer cache, a trade you want decided before the incident, not during it.
Partitioned Tables: Autovacuum’s Different Rules
Declarative partitioning changes PostgreSQL autovacuum tuning mechanics in ways that bite fleets migrating large tables. Autovacuum operates on leaf partitions individually — the partitioned parent has no storage — so per-table threshold overrides must be applied to every leaf (bake them into your partition-creation automation; new monthly partitions minted without the overrides silently regress to defaults). The scale-factor problem partially self-solves, since each leaf is smaller and crosses its 20% threshold sooner, which is a genuine argument for partitioning update-heavy giants.
But two gaps remain in PostgreSQL autovacuum tuning for partitioned tables. First, through PostgreSQL 17, autovacuum does not
analyze the parent: partition-wise statistics exist per leaf, yet the parent-level statistics that inform cross-partition plans only refresh on an explicit
ANALYZE parent_table; — schedule it (pg_cron, weekly or post-load) or watch parent-level join estimates drift as the data ages.
Second, wraparound accounting is per-leaf: a five-year-old cold partition nobody writes still ages toward
autovacuum_freeze_max_age and will eventually trigger an aggressive vacuum of data that hasn't changed since 2021. The clean lifecycle answer is to freeze partitions deliberately as they exit the write window —
VACUUM (FREEZE) orders_2025_q4; in the same maintenance step that sets their indexes to
fillfactor = 100 — after which they are all-frozen, all-visible, skipped by every future vacuum, and instantly removable by
DETACH/DROP with zero vacuum debt.
Monitor the fleet with the same age query used earlier, grouped by partition:
SELECT relname, age(relfrozenxid) FROM pg_class WHERE relname LIKE 'orders_%' ORDER BY 2 DESC; — a healthy time-partitioned table shows ages high only on partitions your freeze step hasn't reached yet, and a flat high-age profile across old partitions means the lifecycle automation is missing its most valuable step.
PostgreSQL Autovacuum Tuning Quick Reference
| Symptom |
Evidence |
Root Cause |
Fix |
| Large table, dead_pct high, rare vacuums |
pg_stat_user_tables |
Scale-factor thresholds too coarse |
Per-table storage parameters |
| Vacuums run but take hours |
log_autovacuum_min_duration |
Cost throttling / low work_mem |
Raise cost_limit, autovacuum_work_mem |
| n_dead_tup never drops after vacuum |
pg_stat_activity, pg_replication_slots |
Horizon held by xact/slot/2PC |
Timeouts; drop dead slots |
| Table size >> live data |
pgstattuple |
Accumulated bloat |
pg_repack (online) |
| xid_age climbing toward 200M |
pg_database age() |
Vacuum throughput deficit |
All of the above, urgently |
Frequently Asked Questions
Should I ever disable autovacuum?
No — not globally, and per-table only for narrow patterns such as tables that are truncated wholesale on every load cycle. Bulk-load pipelines should instead run an explicit
VACUUM ANALYZE as a load step.
Why is my table still large after VACUUM?
Plain
VACUUM reclaims space for reuse inside the table; it returns only trailing empty pages to the operating system. Shrinking a table that is already bloated requires
pg_repack or
VACUUM FULL.
Do HOT updates reduce vacuum pressure?
Substantially. Updates that touch no indexed column and fit on the same page avoid index churn entirely. A lower
fillfactor (for example, 90) on update-heavy tables increases HOT eligibility; monitor
n_tup_hot_upd against
n_tup_upd in
pg_stat_user_tables.
Does PostgreSQL autovacuum tuning differ on managed services?
The mechanics are identical; only access differs. RDS/Aurora and Cloud SQL expose most parameters through parameter groups or flags, but restart-context parameters such as
autovacuum_max_workers follow the provider's reboot workflow, and log access goes through their log interfaces.
How do I vacuum faster mid-incident?
A manual
VACUUM (VERBOSE) tablename; uses the session's
vacuum_cost_limit, which defaults to effectively unthrottled behavior compared with the autovacuum budget. Combined with a session-level increase to
maintenance_work_mem, it is the standard emergency lever.
How often should I revisit PostgreSQL autovacuum tuning?
Review per-table settings whenever a table's size or write pattern changes by an order of magnitude, and audit the fleet quarterly. Thresholds that were correct at 10 million rows are rarely correct at 500 million.
Does PostgreSQL autovacuum tuning replace scheduled maintenance?
No. Tuning keeps steady-state bloat flat, but you still need a plan for reclaiming space that already exists, freezing cold partitions, and rebuilding indexes that have drifted.
PostgreSQL Autovacuum Tuning Checklist: Keep Bloat from Coming Back
MinervaDB's remote DBA and consulting teams run PostgreSQL autovacuum tuning and autovacuum health as a standing program — thresholds tuned per table, wraparound age alerting, and quarterly bloat audits — across on-prem and managed PostgreSQL. If vacuum debt is eating your query latency or your storage bill, contact us at
minervadb.com/contact-us or
contact@minervadb.com.
Validate all PostgreSQL autovacuum tuning parameter and maintenance changes in a staging environment with production-like data volume before rollout, and confirm tested backups and a current DR posture before any table rebuild activity.