DEV Community

Alex Georgiev
Alex Georgiev

Posted on AI-assisted

PostgreSQL 19 beta's parallel autovacuum launches workers but leaves vacuum time unchanged

Autovacuum in PostgreSQL has never been able to split the work of cleaning up a table's indexes across more than one process. A manual VACUUM (PARALLEL n) could do it since PostgreSQL 13, but the background worker that actually runs on your production database at 3am could not. PostgreSQL 19 beta4 changes that, through a new autovacuum_max_parallel_workers setting and an accompanying scoring system that decides which bloated table gets attention first.

I built a table designed to make that matter, bloated it the same way three times over, and measured what parallel index vacuuming actually bought me. The answer, on this workload, was close to nothing on the clock that counts.

The setup

PostgreSQL 19 beta4, run from the official postgres:19beta4 Docker image. A table with six indexes and 400,000 rows, updated twice so that every indexed column changed on every row, leaving 800,000 dead tuples behind:

CREATE TABLE wide_bloat (
    id bigint PRIMARY KEY,
    c1 text, c2 text, c3 text, c4 int, c5 int, c6 timestamp, c7 uuid, payload text
) WITH (autovacuum_enabled = false);
CREATE INDEX idx_wb_c1 ON wide_bloat(c1);
CREATE INDEX idx_wb_c2 ON wide_bloat(c2);
CREATE INDEX idx_wb_c3 ON wide_bloat(c3);
CREATE INDEX idx_wb_c4 ON wide_bloat(c4);
CREATE INDEX idx_wb_c5 ON wide_bloat(c5);
CREATE INDEX idx_wb_c6 ON wide_bloat(c6);
Enter fullscreen mode Exit fullscreen mode

I disabled autovacuum on the table while loading it, bloated it, then flipped autovacuum_enabled back on and let the real autovacuum worker pick it up, reading the outcome from log_autovacuum_min_duration = 0. I rebuilt the table fresh for every trial so each run started from the same 800,000 dead tuples.

The headline number, and why it undersells the result

With autovacuum_max_parallel_workers = 0 (the default — this feature does nothing unless you turn it on), three runs:

Run Wall time Leader CPU
1 1.24s 0.57s
2 1.36s 0.57s
3 1.32s 0.55s

With autovacuum_max_parallel_workers = 4, confirmed in the log by parallel workers: index vacuum: 4 planned, 4 launched in total:

Run Wall time Leader CPU
1 1.23s 0.42s
2 1.38s 0.40s
3 1.14s 0.38s

Wall-clock time averaged 1.31s serial versus 1.25s parallel — about 4%, well inside the run-to-run noise I was already seeing. The CPU time billed to the autovacuum worker itself dropped by a real margin, roughly 30%, because the index-scanning work for five of the six indexes got handed to launched workers instead of done by the leader alone.

That split matters. If you're watching autovacuum's CPU footprint on a busy database, parallel workers genuinely reduce what the worker process itself burns. If you're watching how long a table stays bloated before the dead tuples disappear, this table's bottleneck was the heap scan and the WAL it generated (official instrumentation logged 101,000+ WAL records and 70,000+ dirtied buffers on every run, serial or parallel), not the index cleanup phase that got parallelised. Autovacuum doesn't parallelise the heap scan. On a table where index cleanup actually dominates, I'd expect a different result; on this one it didn't.

What it costs

The same log line reports dead-tuple (TID) storage. Serial runs used 1.45MB. Parallel runs with 4 workers used 3.00MB — roughly double, for the same 800,000 dead tuples, because each launched worker gets its own share of that bookkeeping. On an 800,000-row vacuum this is nothing. On a multi-terabyte table with autovacuum_work_mem already stretched thin and several autovacuum workers each spawning their own parallel helpers, that multiplication is the real thing to budget for, not CPU.

The setting that doesn't do what I assumed

PostgreSQL has had max_parallel_maintenance_workers since version 11, controlling how many workers a manual VACUUM (PARALLEL) or CREATE INDEX can use. I assumed it would also cap autovacuum's new parallel workers. It doesn't.

I set max_parallel_maintenance_workers = 0 and autovacuum_max_parallel_workers = 4 with max_parallel_workers = 8, and autovacuum still logged parallel workers: index vacuum: 4 planned, 4 launched in total. The actual gate, per the PostgreSQL 19 documentation for autovacuum_max_parallel_workers, is this sentence: "The actual number of parallel workers is further limited by max_parallel_workers." Setting that one to 0 instead produced 4 planned, 0 launched, exactly as documented. max_parallel_maintenance_workers isn't mentioned in that page at all. If you're budgeting parallel workers across a cluster, the knob to turn is the global pool, not the maintenance-specific one that governs manual commands.

The scoring system, confirmed

Beta4's release notes mention "fixes for the new autovacuum scoring system" without saying what it does day to day. I built six tables with dead-tuple counts of 100, 500, 5,000, 40,000, 150,000 and 500,000, in that creation order, and enabled autovacuum on all six in the same transaction. A single autovacuum worker picked them up in this order, by timestamp in the log:

p_massive  (500,000 dead)  05:28:13.989
p_huge     (150,000 dead)  05:28:14.081
p_big      ( 40,000 dead)  05:28:14.122
p_med      (  5,000 dead)  05:28:14.141
p_small    (    500 dead)  05:28:14.147
p_tiny     (    100 dead)  05:28:14.149
Enter fullscreen mode Exit fullscreen mode

Strictly descending by dead-tuple count, the exact reverse of creation order. The scoring system does prioritise the most urgent table first, at least for this one signal.

Under concurrency, two things went wrong at once

I capped max_parallel_workers at 4 globally, then started a separate session running a deliberately slow parallel query (pg_sleep() inside a WHERE clause, three workers, over a 1.5 million row table) to hold three of those four slots. With that session running, I re-bloated wide_bloat and enabled autovacuum.

Three consecutive autovacuum attempts over about 40 seconds each logged parallel workers: index cleanup: 4 planned, 0 launched — no slots free, correctly degrading to zero rather than failing. But the more useful finding was in the same log lines: tuples: 0 removed, 1200000 remain, 800000 are dead but not yet removable. The long-running session wasn't just holding parallel workers, it was holding a snapshot that kept those 800,000 dead tuples below the removable horizon. Nothing was deleted, parallel or not, for the full 40 seconds that session stayed open.

The moment I killed that session, the very next cycle logged 4 planned, 4 launched and cleared all 800,000 tuples in about a second. The lesson isn't really about parallel autovacuum: a long-running query anywhere on the database stalls vacuum regardless of how many workers you give it, and parallel autovacuum doesn't change that. It just means the workers you configured can also sit idle at the worst possible time, for the same old reason.

Watching it happen

pg_stat_progress_vacuum in PostgreSQL 19 reports live phase and index progress. Polling it every 300ms during a 1.5 million row autovacuum run on a three-index table caught this:

05:30:13.949 | scanning heap     | heap_blks_scanned=137335/142858 | indexes_processed=0/0
05:30:14.370 | vacuuming indexes | heap_blks_scanned=142858/142858 | indexes_processed=2/4
05:30:14.791 | vacuuming heap    | heap_blks_scanned=142858/142858 | indexes_processed=0/0
Enter fullscreen mode Exit fullscreen mode

indexes_total and indexes_processed are enough to tell whether a slow vacuum is stuck scanning the heap or working through indexes, which is the question I actually want answered at 3am.

What I got wrong on the way

My first run of that test looked like a bug. I set max_parallel_maintenance_workers = 0 with ALTER SYSTEM, called pg_reload_conf(), got back applied: t from pg_file_settings — and SHOW max_parallel_maintenance_workers still said 8. I spent ten minutes convinced the setting wasn't taking effect at all, before I remembered why: I'd started that container with -c max_parallel_maintenance_workers=8 on the docker run line. A value set that way is fixed for as long as the server runs. ALTER SYSTEM writes to postgresql.auto.conf happily, the file even says it applied, but nothing reads it back over a command-line override. I had to kill the container and start a fresh one without that flag before the test told me anything real.

There's a second, smaller one worth naming: my first contention test used a global max_parallel_workers of 8 against a competing query holding only 3 workers, leaving 5 free for autovacuum's request of 4 — plenty of room, so nothing contended and I nearly wrote up "no effect" as a finding. Dropping the pool to 4 total is what actually forced the two requests to compete, and that's the version that produced something worth reporting.

Run it yourself

docker run -d --name pgq19 -e POSTGRES_PASSWORD=postgres -p 15432:5432 postgres:19beta4 \
  -c log_autovacuum_min_duration=0 -c autovacuum_naptime=5 \
  -c autovacuum_vacuum_threshold=50 -c autovacuum_vacuum_scale_factor=0.0 \
  -c autovacuum_vacuum_cost_delay=0 -c maintenance_work_mem=256MB \
  -c max_parallel_workers=8 -c autovacuum_worker_slots=16 -c autovacuum_max_workers=3

docker exec pgq19 psql -U postgres -c "
CREATE TABLE wide_bloat (id bigint PRIMARY KEY, c1 text, c2 text, c3 text, c4 int, c5 int, c6 timestamp)
  WITH (autovacuum_enabled = false);
CREATE INDEX idx_wb_c1 ON wide_bloat(c1); CREATE INDEX idx_wb_c2 ON wide_bloat(c2);
CREATE INDEX idx_wb_c3 ON wide_bloat(c3); CREATE INDEX idx_wb_c4 ON wide_bloat(c4);
CREATE INDEX idx_wb_c5 ON wide_bloat(c5); CREATE INDEX idx_wb_c6 ON wide_bloat(c6);
INSERT INTO wide_bloat SELECT g, 'v'||(g%5000), 'c'||(g%300), md5(g::text), g%1000, g%97, now()
  FROM generate_series(1,400000) g;
UPDATE wide_bloat SET c1='v2_'||(id%5000), c2='c2_'||(id%300), c3=md5((id+1)::text);
UPDATE wide_bloat SET c1='v3_'||(id%5000), c2='c3_'||(id%300), c3=md5((id+2)::text);
"

# serial baseline — ALTER SYSTEM must be its own statement, not chained with ;
docker exec pgq19 psql -U postgres -c "ALTER SYSTEM SET autovacuum_max_parallel_workers = 0;"
docker exec pgq19 psql -U postgres -c "SELECT pg_reload_conf();"
docker exec pgq19 psql -U postgres -c "ALTER TABLE wide_bloat SET (autovacuum_enabled = true);"

# then rebuild the table and repeat with:
# ALTER SYSTEM SET autovacuum_max_parallel_workers = 4;
# and read the result from: docker logs pgq19 | grep -A20 'automatic vacuum of table'
Enter fullscreen mode Exit fullscreen mode

What I'd do with this

If your autovacuum workers are CPU-starved on wide tables, turning autovacuum_max_parallel_workers up is a real lever on that specific cost, and it's off by default so nobody gets it by accident on an upgrade. It is not a lever on how long your table stays bloated if the heap scan or the WAL it writes is the actual bottleneck, so measure your own table before assuming it helps — pg_stat_progress_vacuum's phase column will tell you which one you've got. And budget the global max_parallel_workers pool, not max_parallel_maintenance_workers, because that's the setting that actually gates this, and a long transaction left open anywhere will still starve a bloated table whether or not you've given autovacuum workers to spend.

Top comments (0)