Vacuum and Bloat7 min read

Autovacuum Cost Throttling: Tuning So Vacuum Keeps Up on Big Tables

The default autovacuum cost settings throttle vacuum to a pace modern hardware laughs at. On big write-heavy tables, that means dead tuples win. Here is how to retune.

The symptom was a 600GB table whose query plans were slowly degrading over weeks. Dead tuples were climbing into the hundreds of millions, index scans were dragging through bloated pages, and autovacuum was, by every log line, running on the table constantly. It was running constantly and losing. The table's write rate generated dead tuples faster than vacuum was permitted to remove them, and "permitted" is the right word, because nothing was broken: the default cost-based throttling was doing exactly what it is configured to do, capping vacuum's I/O at a pace that made sense when those defaults were chosen. On modern hardware with a write-heavy table, that pace is a losing one.

Cost-based vacuum delay is one of those subsystems that stays invisible until it is the whole problem. Here is how the throttle actually works, how to tell whether you are falling behind, and how to retune per table without turning autovacuum into an I/O storm.

How the cost throttle actually works

Manual VACUUM and autovacuum workers share a simple accounting system. Every page vacuum touches accrues cost: vacuum_cost_page_hit (default 1) for a page found in shared_buffers, vacuum_cost_page_miss (default 2 since PostgreSQL 12; it was 10 before) for a page read from storage, and vacuum_cost_page_dirty (default 20) for a page vacuum has to dirty. When the accumulated cost crosses vacuum_cost_limit (default 200), the process sleeps for vacuum_cost_delay milliseconds. Autovacuum's variants govern the background workers: autovacuum_vacuum_cost_delay defaults to 2ms, and autovacuum_vacuum_cost_limit defaults to -1, meaning it inherits the 200 from vacuum_cost_limit. Manual VACUUM, by contrast, defaults to a cost delay of 0, which is why a hand-run VACUUM sprints while autovacuum jogs.

Do the arithmetic on the defaults and the ceiling appears. Worst case, every page is dirtied: 200 cost divided by 20 per page is 10 pages per sleep cycle, and a 2ms sleep per cycle yields roughly 5,000 pages per second, on the order of 40MB/s of table processing. On a several-hundred-gigabyte table with writes arriving all day, vacuum is permanently behind, and being behind is self-reinforcing: dead tuples make the table bigger, a bigger table takes longer to vacuum, and the next pass starts further in debt.

Measuring whether you are losing

Two catalogs answer almost everything. pg_stat_user_tables shows the accumulated state per table: n_dead_tup, the estimate of dead row versions; last_autovacuum and autovacuum_count to see how often workers visit; and n_mod_since_analyze for the analyze side of the house:

SELECT relname, n_live_tup, n_dead_tup,
       last_autovacuum, autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;

A table whose n_dead_tup ratchets upward across days, despite regular autovacuum runs, is the signature of vacuum losing the race. On a big write-heavy table the throttle is the usual suspect, but check one thing before retuning: a pinned vacuum horizon from a long-lived snapshot or a lagging replication slot stops vacuum from removing dead tuples no matter how fast it runs, and the curve looks identical. For a live view of a running pass, pg_stat_progress_vacuum shows the phase, heap blocks scanned versus total, and dead tuples collected so far, which tells you whether the current pass will even make a dent. And since PostgreSQL 16, pg_stat_io breaks reads and writes down by backend type, so you can see autovacuum's actual I/O footprint and judge how much headroom the disks have before you spend it on faster vacuuming.

Retuning: per table first, global second

The best lever is per-table storage parameters, because the table that needs aggressive vacuum is rarely the whole database. For a hot, write-heavy table, loosen or remove the throttle just for it:

ALTER TABLE events SET (
  autovacuum_vacuum_cost_delay = 0,
  autovacuum_vacuum_cost_limit = 10000
);

Setting the delay to 0 disables sleeping for that table's vacuum entirely; the raised limit keeps the intent explicit if someone later reinstates a global delay. A gentler middle ground that works well in shared environments is a small fractional delay, the setting accepts values like 0.2ms, paired with a limit in the low thousands. That lifts throughput by an order of magnitude while still leaving I/O for everyone else. Tune in steps and watch two curves together: the table's dead-tuple count and the storage latency. You are buying bloat control with I/O, and the right price depends on your hardware's headroom. If many tables need the same treatment, change the globals with ALTER SYSTEM, but expect per-table overrides to remain the precise instrument, and remember that autovacuum_max_workers bounds how many tables can be vacuumed concurrently regardless of throttle settings.

Two related knobs deserve a sentence each. maintenance_work_mem sizes each worker's memory, and on older PostgreSQL versions a small value forced vacuum into multiple index-scan passes per run; PostgreSQL 17 replaced the dead-TID array with a far more memory-efficient structure, which largely retires that particular failure mode. And autovacuum_vacuum_scale_factor, the fraction of the table that must change before vacuum triggers, pairs with these settings: a huge table with the default 0.2 triggers vacuum only after enormous numbers of dead tuples, so hot tables usually want a much smaller scale factor alongside the looser throttle. If you want a genuinely fixed trigger instead, set the scale factor to 0 and lean on autovacuum_vacuum_threshold alone, because the threshold is added to the scale-factor term rather than replacing it. Triggering more often does not help if each pass is still throttled to a crawl, which is why the cost settings come first in this article.

The wraparound reminder

Anti-wraparound vacuums, the aggressive passes PostgreSQL runs as tables approach autovacuum_freeze_max_age, are also subject to the cost throttle. A cluster that has fallen far behind on freezing will run those passes at your configured pace, and if that pace is the default crawl, the database can grind toward emergency territory while vacuum appears to be "working" the whole time. There is one safety net below that: once a table's age crosses vacuum_failsafe_age, default 1.6 billion transactions, PostgreSQL drops cost-based delay entirely and skips nonessential work to finish freezing at full speed, so the throttle does its damage in the long runway before that point. If you ever see transaction ID age climbing into the hundreds of millions on large tables, treat throttle settings as part of the fix, not just the freeze parameters. Our vacuum guide covers the freeze side of the story, and the bloat measurement post shows how to quantify what a slow throttle has already cost you.

Watching the balance with MonPG

The balance this post describes gets re-struck every time the workload changes, so dead-tuple trends, autovacuum run history, and I/O signals need to live somewhere you actually look. That is the gap MonPG closes for PostgreSQL today: autovacuum health, dead tuple accumulation, table bloat, and query history in one workflow, turning a table that is quietly losing to its own write rate into a trend line instead of a degradation incident three weeks later. The PostgreSQL monitoring page walks through what that looks like in practice, and the blog has the rest of this series.