Module 7: Statistics, Autovacuum & Planner
Autovacuum: defaults, per-table tuning, and monitoring
Autovacuum is PostgreSQL's daemon that runs VACUUM and ANALYZE automatically on your tables. It's the difference between having a healthy database and one that progressively degrades — without it, bloat would accumulate nonstop and stats would never get updated. It's active by default and that's good.
What's not so good is that its default parameters were calibrated for small tables and moderate workloads. In 2026, with tables of millions of rows and sustained workloads, the defaults let a lot of bloat accumulate before acting. A 100-million-row table with the default setting doesn't get a VACUUM until it has ~20 million dead tuples. By then, the queries are already degraded.
In this capsule you're going to learn exactly how autovacuum decides when to run, the three parameters to tune (vacuum_scale_factor, analyze_scale_factor, naptime), and why the answer is almost always to adjust per-table, not globally. You're also going to learn to monitor whether autovacuum is keeping up or whether there are tables where it's falling behind.
What autovacuum does exactly
Autovacuum runs two operations on each pass:
VACUUM: cleans up dead tuples, updates the visibility map, frees space within pages for reuse. It doesn't release physical space to the OS — onlyVACUUM FULLorpg_repackdoes that.ANALYZE: updates the statistics the planner consumes (the ones you saw in capsules 02-04).
Autovacuum runs every autovacuum_naptime seconds (default: 60s). On each pass, it decides which tables need VACUUM and which need ANALYZE based on thresholds:
threshold_vacuum = autovacuum_vacuum_threshold
+ (autovacuum_vacuum_scale_factor × n_live_tup)
threshold_analyze = autovacuum_analyze_threshold
+ (autovacuum_analyze_scale_factor × n_live_tup)
Defaults:
autovacuum_vacuum_threshold = 50(50 dead tuples)autovacuum_vacuum_scale_factor = 0.2(20% of the table)autovacuum_analyze_threshold = 50autovacuum_analyze_scale_factor = 0.1(10%)
For a 100-million-row table:
- VACUUM runs when there are
50 + 0.2 × 100M = 20,000,050dead tuples. 20 million. - ANALYZE runs when
50 + 0.1 × 100M = 10,000,050rows have been modified. 10 million.
For a 1,000-row table:
- VACUUM runs when there are
50 + 0.2 × 1000 = 250dead tuples. - ANALYZE runs when
50 + 0.1 × 1000 = 150rows have been modified.
Notice that the behavior is diametrically opposite: on small tables the threshold is low (autovacuum reacts fast); on large tables the threshold is enormous (autovacuum waits a long time to act). It's a bad default for 2026 — modern workloads have large tables with constant changes.
The right pattern: per-table tuning
The solution isn't to lower autovacuum_vacuum_scale_factor globally — that would make autovacuum run too much on small tables, spending CPU unnecessarily. The solution is to identify the problematic tables and adjust per-table.
-- Large table with many UPDATEs: aggressive autovacuum
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.05, -- 5% instead of 20%
autovacuum_analyze_scale_factor = 0.02 -- 2% instead of 10%
);
-- Table with DELETE/UPDATE bursts: low absolute threshold
ALTER TABLE event_logs SET (
autovacuum_vacuum_threshold = 1000, -- React sooner
autovacuum_vacuum_scale_factor = 0.05
);
-- Append-only table that only grows (logs, audit): normal or lower autovacuum
-- Nothing to adjust — the default autovacuum is fine here
To check per-table settings:
SELECT
relname,
reloptions
FROM pg_class
WHERE reloptions IS NOT NULL
ORDER BY relname;
Output:
relname | reloptions
-------------+----------------------------------------
event_logs | {autovacuum_vacuum_threshold=1000,autovacuum_vacuum_scale_factor=0.05}
orders | {autovacuum_vacuum_scale_factor=0.05,autovacuum_analyze_scale_factor=0.02}
To clear settings and go back to defaults:
ALTER TABLE orders RESET (autovacuum_vacuum_scale_factor, autovacuum_analyze_scale_factor);
How to choose which tables to tune
It's not a guessing task. There's a systematic pattern.
Step 1: identify tables with high bloat
SELECT
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_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY dead_pct DESC
LIMIT 20;
Filter to the top 20 with the most percentage bloat. These are your candidates for more aggressive autovacuum.
Step 2: identify tables with delayed autovacuum
SELECT
relname,
n_dead_tup,
last_autovacuum,
EXTRACT(EPOCH FROM (NOW() - last_autovacuum))/3600 AS hours_since_vacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 50000
AND (last_autovacuum IS NULL OR last_autovacuum < NOW() - INTERVAL '24 hours')
ORDER BY hours_since_vacuum DESC NULLS FIRST;
Tables with many dead tuples but that haven't been autovacuumed in 24h have delayed autovacuum. It can be for two reasons:
- Threshold not reached (the table doesn't reach the
autovacuum_vacuum_scale_factor). - Autovacuum running but slow (full queues, long naptime).
Step 3: identify the change pattern
SELECT
relname,
n_tup_ins, -- total INSERTs
n_tup_upd, -- total UPDATEs
n_tup_del, -- total DELETEs
n_tup_hot_upd, -- HOT UPDATEs (without updating indexes)
ROUND(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 2) AS hot_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 10000
ORDER BY n_tup_upd DESC
LIMIT 10;
Tables with many UPDATEs and low hot_pct (<50%) are the ones that accumulate the most bloat. Those need aggressive autovacuum.
Step 4: apply per-table adjustments
For an orders table with dead_pct = 35%, a high number of UPDATEs and hot_pct = 30%:
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.05, -- VACUUM at 5%
autovacuum_analyze_scale_factor = 0.02, -- ANALYZE at 2%
autovacuum_vacuum_cost_delay = 10 -- Ms of pause between pages (default 2ms)
);
autovacuum_vacuum_cost_delay controls how aggressive VACUUM is in terms of I/O. Raising it makes it gentler (less impact on normal queries) but slower. For large tables on decent hardware, 10ms is reasonable.
Step 5: validate after a day
SELECT
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
FROM pg_stat_user_tables
WHERE relname = 'orders';
If dead_pct dropped to <10% and last_autovacuum is recent, the adjustment worked. If it's still high, the next step is to check whether the table has so many UPDATEs that no autovacuum can keep up — in which case you need to reconsider the UPDATE pattern (capsule 05 covered that case).
Autovacuum workers and throughput
Autovacuum runs with several workers in parallel. By default:
autovacuum_max_workers = 3(3 simultaneous workers).autovacuum_naptime = 60s(check every 60s).
For databases with many active tables (50+), 3 workers can be a bottleneck. Raising it to 5-8 workers is reasonable, especially if you have hardware with available CPU.
-- In postgresql.conf or via ALTER SYSTEM
ALTER SYSTEM SET autovacuum_max_workers = 6;
ALTER SYSTEM SET autovacuum_naptime = 30; -- Check every 30s instead of 60s
SELECT pg_reload_conf();
Check active workers at this moment:
SELECT
pid,
datname,
relname,
state,
query
FROM pg_stat_activity
JOIN pg_class ON relname = (regexp_match(query, 'autovacuum: (?:VACUUM ANALYZE|VACUUM|ANALYZE) public\.(\w+)'))[1]
WHERE backend_type = 'autovacuum worker';
(This query is simplified. Reality is messier parsing the query text — but it gives the principle.)
The "anti-wraparound vacuum" problem
PostgreSQL uses a 32-bit transaction counter (XID). If a VACUUM is never done, eventually the counter runs out of values and the database can't accept more transactions — it "wraps around" (wraparound). To prevent this, autovacuum has an emergency mode: anti-wraparound vacuum.
When a table approaches wraparound, autovacuum locks it and does a VACUUM in aggressive mode, ignoring other parameters. This can take hours on large tables and blocks DDL operations during that time. In production, seeing "anti-wraparound vacuum running" is a sign that your normal autovacuum was behind for a long time.
To detect tables in danger:
SELECT
relname,
age(relfrozenxid) AS xid_age,
pg_size_pretty(pg_total_relation_size(oid)) AS size
FROM pg_class
WHERE relkind = 'r'
AND age(relfrozenxid) > 100000000 -- 100M XIDs without freeze
ORDER BY age(relfrozenxid) DESC;
autovacuum_freeze_max_age = 200000000 (default 200M XIDs) is when it triggers anti-wraparound. If you see tables near that number, it's an alert.
To avoid this scenario on large tables, adjust:
ALTER TABLE huge_table SET (autovacuum_freeze_max_age = 100000000);
This makes the anti-wraparound run sooner (while there's still margin) instead of in an emergency.
Traps and common mistakes
1. Lowering autovacuum_vacuum_scale_factor globally.
This makes autovacuum run constantly over all tables, spending CPU/I/O. Per-table is the right answer — only where you really need it.
2. Disabling autovacuum to "manage it manually with scripts".
Terrible idea. Any real app has hundreds of tables with different patterns. Managing it manually means forgetting tables, generating wraparound, and having progressively degraded databases. Autovacuum works — it just needs tuning.
-- ❌ DON'T DO THIS
ALTER TABLE orders SET (autovacuum_enabled = false);
The only legitimate exception is disabling autovacuum temporarily during massive bulk loads where you know you're going to run a manual ANALYZE at the end. And re-enabling it afterward.
3. Not monitoring when autovacuum runs vs when it should run.
If you never check last_autovacuum and n_dead_tup, you don't know whether autovacuum is keeping up. Having a dashboard (Grafana, Datadog) with those metrics is basic operational discipline.
4. Thinking autovacuum handles indexes automatically.
Autovacuum runs VACUUM, which cleans up the dead pointers in indexes, but it doesn't rebuild fragmented indexes. For that you need REINDEX CONCURRENTLY (manual or via a monthly cron). Bloat in indexes doesn't cure itself.
5. Forgetting that ANALYZE is part of autovacuum.
Autovacuum does VACUUM AND ANALYZE. If you tune autovacuum_vacuum_scale_factor aggressively but forget autovacuum_analyze_scale_factor, you end up with old stats even though the table is clean. Tune both together.
6. Assuming more workers = faster.
Raising autovacuum_max_workers helps only if you have many tables that need vacuum simultaneously. If you only have one problem table, five workers aren't going to process it faster (PostgreSQL doesn't parallelize VACUUM within a table on versions <13). See autovacuum_vacuum_cost_limit to speed up the individual VACUUM.
7. Tuning without measuring the impact.
After any autovacuum change, monitor for at least 24-48h. Sometimes "more aggressive" improves bloat but degrades other metrics (query latency during the VACUUM). The right balance requires observation.
Exercise: identify and tune problematic tables
You're going to simulate a table with accumulated bloat and apply systematic tuning.
Setup:
DROP TABLE IF EXISTS user_sessions;
CREATE TABLE user_sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB
);
CREATE INDEX idx_sessions_user ON user_sessions(user_id);
CREATE INDEX idx_sessions_last_seen ON user_sessions(last_seen);
-- Insert 100k sessions
INSERT INTO user_sessions (user_id, metadata)
SELECT
(random() * 1000)::INT + 1,
jsonb_build_object('ua', 'Mozilla/5.0', 'ip', '192.168.1.' || (random() * 255)::INT)
FROM generate_series(1, 100000);
ANALYZE user_sessions;
Step 1: simulate frequent updates (typical of activity tracking).
-- 10 rounds of UPDATE — simulating "user activity tracking" that runs often
DO $$
BEGIN
FOR i IN 1..10 LOOP
UPDATE user_sessions SET last_seen = NOW() WHERE user_id IN (
SELECT (random() * 1000)::INT + 1 FROM generate_series(1, 50)
);
END LOOP;
END $$;
Step 2: measure bloat.
SELECT
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,
n_tup_upd,
n_tup_hot_upd,
ROUND(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 2) AS hot_pct
FROM pg_stat_user_tables
WHERE relname = 'user_sessions';
Question: what percentage are HOT updates? Why aren't they 100%?
Step 3: simulate massive load over several hours (we're going to force the scenario).
-- Repeat the UPDATE block many more times
DO $$
BEGIN
FOR i IN 1..100 LOOP
UPDATE user_sessions SET last_seen = NOW() WHERE id BETWEEN
(random() * 99000)::INT + 1 AND (random() * 99000)::INT + 100;
END LOOP;
END $$;
SELECT 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
FROM pg_stat_user_tables WHERE relname = 'user_sessions';
Question: did we reach dead_pct >20%? With the default autovacuum_vacuum_scale_factor = 0.2, should autovacuum have run?
Step 4: check whether autovacuum ran.
SELECT relname, last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables WHERE relname = 'user_sessions';
If in a local demo session it didn't run, it's because autovacuum_naptime hasn't passed yet. In production it would have run (eventually). But you can force a manual VACUUM:
VACUUM user_sessions;
Step 5: apply per-table tuning and justify it.
ALTER TABLE user_sessions SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_analyze_scale_factor = 0.02
);
-- Verify
SELECT relname, reloptions FROM pg_class WHERE relname = 'user_sessions';
Question: why is 0.05 reasonable for this specific table? Why not 0.02 even more aggressive?
See solution
Step 2 — HOT updates:
hot_pct would typically be 0% or very low in this setup. Why: we update last_seen, which is indexed (idx_sessions_last_seen). When an indexed column is updated, PostgreSQL can't use HOT — it has to update the index. If we removed the index or only updated non-indexed columns, hot_pct would be ~95%.
Step 3 — dead_pct:
After many UPDATEs on the same rows, dead_pct goes from 0% to 50%+. Each UPDATE creates a dead tuple. With the default 0.2, autovacuum eventually runs and cleans up, but in a local demo it depends on naptime.
Step 5 — justification for 0.05:
This table:
- Has very frequent UPDATEs (activity tracking).
- Each UPDATE creates a dead tuple (because there's an index on the updated column).
- Bloat accumulates fast if you wait for 20%.
With 0.05, autovacuum runs when there are 5,000 dead tuples (in 100k rows) — more timely. With 0.02 it runs when there are 2,000 — too aggressive, you spend CPU unnecessarily. 0.05 is the sweet spot.
General lessons:
- Tables with frequent updates on indexed columns → aggressive per-table autovacuum.
- Each extra index is a cost on updates → consider whether you need all the indexes.
- The "tracking column" pattern (last_seen, last_login, view_count) is the main bloat producer → consider alternative designs (Redis, a separate table).
Summary and next step
What you learned:
- Autovacuum runs
VACUUMandANALYZEautomatically, but the defaults (scale_factor = 0.2for vacuum,0.1for analyze) are bad for large tables. - The right strategy is per-table tuning, not global.
ALTER TABLE ... SET (autovacuum_vacuum_scale_factor = 0.05). - Identify problematic tables with
pg_stat_user_tables:dead_pct > 20%,hot_pct < 50%, highn_tup_upd. - Tables with frequent UPDATEs on indexed columns are the main candidates for aggressive autovacuum.
- Anti-wraparound vacuum is the emergency mode. Seeing it in production is a sign of delayed maintenance.
- Raising
autovacuum_max_workershelps with many tables;autovacuum_vacuum_cost_delaycontrols I/O aggressiveness.
Before moving on, you should be able to:
- Calculate which
dead_pcttriggers autovacuum givenscale_factorandn_live_tup. - Identify the top 5 problematic tables in a database with
pg_stat_user_tables. - Apply
ALTER TABLE ... SETwith justification (not copying numbers from blogs). - Detect tables at risk of wraparound with
age(relfrozenxid).
In the next capsule we go to the "it's already too late" case: you have a table with 60% accumulated bloat, autovacuum isn't going to recover the physical space (it only marks it reusable), and you need to give the space back to the operating system. The default option is VACUUM FULL — but it locks the table for hours. The right option for production is pg_repack, which does the same work online. You're going to learn when to use each one and how to run pg_repack safely.
Resources
- PostgreSQL Docs — Autovacuum — reference for all the parameters.
- PostgreSQL Docs — Routine Vacuuming — deep explanation of maintenance.
- pganalyze — Autovacuum tuning — modern operational checks.
- Crunchy Data — Autovacuum Tuning Basics — a practical guide with cases.
- depesz — Why is my autovacuum not running? — diagnosis of delayed autovacuum.
- Tomas Vondra — VACUUM internals — deep talks.
- Robert Haas — Anti-wraparound vacuum — posts on wraparound and critical maintenance.
Capsule 06 of 08 — Module 7 — Database Performance & Query Tuning Guide