pgrun.dev
← All posts

Postgres 19 makes autovacuum smarter and more parallel

· Alex Shapalov

Postgres 19 makes autovacuum smarter and more parallel. Now Postgres can:

→ Launch multiple autovacuum workers
→ Calculate a risk score for each table
→ Process the highest-risk tables first
→ Clean several tables in parallel

Before Postgres 19, autovacuum could become a long queue: one worker, one table at a time, high-risk tables waiting behind less urgent work.

The queue you never see

To be precise about the old behaviour: autovacuum has had a pool of workers for years — autovacuum_max_workers, default 3, shared by every database in the cluster. Two limits defined how far that pool went.

A table was always one worker's job. However large the table, a single process walked its heap and every one of its indexes alone. Manual VACUUM learned to process indexes in parallel back in Postgres 13; autovacuum never got to use it.

Workers picked tables in no useful order. Within a database, a worker built its to-do list roughly in catalog order and worked through it. Nothing in that order asked which table was about to become a problem. A table drowning in dead tuples — or drifting toward transaction-ID wraparound — waited exactly as long as its position in the list said, behind tables that barely needed attention.

You can watch the queue form on any busy cluster today:

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

If the top of that list keeps growing while last_autovacuum stays old, your riskiest tables are queuing behind work that matters less. On a schema with a few thousand tables — one per tenant, say — that queue is the whole story of your bloat.

A risk score for each table

Postgres 19 replaces catalog order with a score. When tables qualify for a vacuum they are ranked by how overdue they are, and workers take from the top of the ranking rather than the top of the catalog.

Two pressures dominate the score, mirroring the two reasons autovacuum exists:

  • Wraparound age. How close relfrozenxid is to autovacuum_freeze_max_age. This is the failure mode that can take the whole database down, so it outranks everything else.
  • Dead-tuple pressure. Not the raw count, but how far past its own vacuum threshold a table is — a small, hot table at twenty times its threshold scores ahead of a huge cold one just barely over the line.

The number the score watches most is one you can already read yourself:

SELECT relname, age(relfrozenxid) AS xid_age
FROM pg_class
WHERE relkind = 'r'
ORDER BY age(relfrozenxid) DESC
LIMIT 5;

The effect is the scheduler you would design by hand: the table that is about to hurt is the table a worker picks up next. "Eventually" stops being the plan.

Several workers, one table

The second change: autovacuum can now bring a team to a single table. The parallel index machinery that manual VACUUM (PARALLEL n) has used since Postgres 13 becomes available to autovacuum, opt-in per table:

ALTER TABLE events SET (autovacuum_parallel_workers = 4);

A big table's vacuum time is usually dominated by its indexes — each one is a full pass. Give a table with eight indexes a few parallel workers and those passes overlap instead of queuing inside the queue. A global cap keeps team sizes honest, so one monster table cannot starve the rest of the cluster of worker processes.

Opt-in per table is the right default. Most tables do not need a team. The 200 GB events table with nine indexes does.

The knob everyone forgets

More workers does not automatically mean more vacuuming, because autovacuum's I/O budget is shared. autovacuum_vacuum_cost_limit is split across every active worker: run three workers and each gets roughly a third of the budget. Add workers without raising the limit and you have organised a faster queue to the same narrow tap.

So if 19 tempts you into higher worker counts, move the budget with them:

autovacuum_max_workers        = 6
autovacuum_vacuum_cost_limit  = 2000   -- split across all active workers

On fast local disks that limit is usually far too shy anyway — it was tuned for storage that hated random I/O. Vacuum on NVMe is cheap; bloat never is.

Should you care yet

Postgres 19 reached Beta 1 on 4 June 2026, with the release expected in September, so this is something to plan for rather than something to ship — and scheduling details and setting names are exactly the kind of thing that can still shift between beta and GA. The shape, though, is settled, and it changes who has to think about vacuum:

  • Many-table fleets — multitenant schemas, partitioned time series — get the biggest win. Risk ordering fixes the pathology where ten thousand quiet tables bury the three that matter.
  • A few huge tables get parallel vacuum without a cron job full of manual VACUUM (PARALLEL).
  • Wraparound stragglers stop being a 3 a.m. story. The anti-wraparound vacuum that used to arrive in a panic now arrives early, at the top of the queue.

And if you run 14 through 18 today, the old mitigation is unchanged and worth doing anyway: lower the per-table thresholds on your hot tables, so vacuums are small and frequent instead of rare and enormous.

Autovacuum has always been the part of Postgres you tune by folklore. Scoring tables by risk and letting workers team up turns it into something closer to a real scheduler. We watch dead tuples and wraparound age on every database we run — 19 is the first release where Postgres worries about them in the same order we do.