2026-10-17 · 8 min read

Four Parallel Workers Launched and the Clock Didn't Move

Flat isometric dark illustration of a database table split into a wide grey scan zone and a narrow indigo index zone feeding four worker nodes, ending at one green node.

Four parallel workers launched. The vacuum finished in 1.31 seconds instead of 1.24. That is four percent — well inside the gap between any two runs of the same command on the same machine.

That number comes from a careful benchmark run against PostgreSQL 19 beta4 published on DEV Community this week. The author built a table with six indexes and 400,000 rows, updated every row twice to leave 800,000 dead tuples behind, and let real autovacuum — not a manual VACUUM — clean it up. Then he did it again with the new parallel workers enabled. The workers showed up in the log. The clock did not move.

That result is worth unpacking, because "the feature works and it didn't help" is one of the most common and most misread outcomes in performance work.

What autovacuum is actually doing at 3am

PostgreSQL doesn't overwrite rows. An UPDATE writes a new version of the row and leaves the old one on disk. The old version is a dead tuple — invisible to your queries, still occupying space, still costing you a disk page read every time the table gets scanned. Autovacuum is the background process that goes and finds them.

It works in two distinct phases:

  1. The heap scan. Read the table file page by page, decide which tuples are still visible, and record the dead ones. This phase reads a lot, dirties memory buffers, and writes WAL — PostgreSQL's write-ahead log, the journal every change must pass through before it reaches the data files.
  2. The index cleanup. For each index on the table, remove the entries pointing at dead tuples. This is the phase the new parallel workers get involved in.

Since PostgreSQL 13 you could run VACUUM (PARALLEL n) by hand and split that second phase across processes. Autovacuum — the one that actually runs on your production box — could not. PostgreSQL 19 adds autovacuum_max_parallel_workers, and with it a scoring system that decides which bloated table gets attention first.

The measurement

Same table, same 800,000 dead tuples, rebuilt fresh for each trial, with log_autovacuum_min_duration = 0 so every run reports itself:

  • Default (feature off): 1.24s, 1.36s, 1.32s wall time. The autovacuum leader process burned 0.57s, 0.57s, 0.55s of CPU.
  • Four parallel workers, confirmed in the log as "index vacuum: 4 planned, 4 launched": 1.23s, 1.38s, 1.14s wall time. The leader burned 0.42s, 0.40s, 0.38s.

Wall-clock averages: 1.31 seconds serial, 1.25 seconds parallel. About 4%. The leader's own CPU cost dropped roughly 30%, which is real and is not nothing.

That's the whole story in two numbers. If your question is "how long does a table stay bloated before the dead tuples are gone," the answer barely changed. If your question is "how much CPU does the autovacuum process itself consume on a busy box," the answer improved by a third.

Those are different questions. Most teams think they're asking the first one and are secretly measuring the second.

Parallelism is a multiplier, and it multiplied the wrong term

Amdahl's law, stated without the algebra: if you split 20% of a job across many workers, the best case is that the 20% disappears. You still pay the other 80% at full price. Speedup is capped at five times, and you will not get five times.

In this run, the instrumentation points straight at where the time went. Every single run — serial or parallel — logged more than 101,000 WAL records and more than 70,000 dirtied buffers. That's the heap scan doing its job, and nothing in this feature touches it. The parallel workers took a slice of the index cleanup, and the index cleanup was not the long pole.

On a table where index cleanup does dominate — many indexes, large indexes, relatively few live rows — the same setting should produce a very different result. That's not a defense of the feature; it's the point. The feature is conditional, and the condition is a property of your schema, not of the release notes.

One thing that did get better: the queue order

The same beta adds a scoring system for choosing which table autovacuum attacks first. The benchmark built six tables with 100, 500, 5,000, 40,000, 150,000 and 500,000 dead tuples, enabled autovacuum on all of them in one transaction, and watched a single worker pick them up in order: strictly descending by dead-tuple count, the exact reverse of creation order.

That's a small, unglamorous improvement, and it matters more than it sounds. The table with the most garbage is the one whose indexes your queries are dragging through right now.

The knob you'll reach for is the wrong knob

Here's the part I'd tape to a monitor. PostgreSQL has had max_parallel_maintenance_workers since version 11. It governs how many workers a manual VACUUM (PARALLEL) or a CREATE INDEX may use. It is the obvious place to look if you want to cap autovacuum's new helpers.

It doesn't cap them. In the benchmark, max_parallel_maintenance_workers was set to 0 with autovacuum_max_parallel_workers at 4 — and autovacuum still logged "4 planned, 4 launched." The setting that actually gates it is max_parallel_workers, the global pool shared with ordinary parallel queries. Set that one to 0 and you get "4 planned, 0 launched," exactly as the PostgreSQL documentation says in a single sentence most people skim past.

That has a practical consequence worth naming: your autovacuum parallelism now competes for slots with the reporting query someone kicks off at 2pm. In the same benchmark, with three of four global slots held by a deliberately slow parallel query, autovacuum logged "4 planned, 0 launched" and quietly degraded to serial. Correct behaviour. Silent behaviour. If you weren't reading the log, you'd never know your new setting wasn't doing anything.

The cost that doesn't show up in the timing

Parallel workers are not free. Each launched worker gets its own share of the dead-tuple bookkeeping — the TID store, the array of tuple identifiers autovacuum accumulates so it can clean the indexes afterwards.

On this 800,000-tuple table: 1.45 MB serial, 3.00 MB with four workers. Roughly double, for exactly the same amount of garbage.

At that size, nobody cares. On a multi-terabyte table where autovacuum_work_mem is already tight, multiplied across several tables being vacuumed concurrently, each spawning its own helpers, that multiplication is what you should be budgeting for — not CPU time, which went down.

A short decision framework

Before you set autovacuum_max_parallel_workers above zero, answer these five:

  1. Is index cleanup actually your bottleneck? Turn on log_autovacuum_min_duration = 0 for one fat table and watch where the time goes. If the heap scan dominates, this feature cannot help you.
  2. How many indexes, and how big? Parallel index cleanup distributes work across indexes. One enormous index does not split.
  3. What is your max_parallel_workers headroom during the hours autovacuum runs? Near-zero headroom buys you planning overhead and no launch.
  4. Does your memory budget survive doubling the TID store? Multiply by concurrent vacuum workers, not by one.
  5. Which metric are you optimizing? Leader CPU or time-to-unbloat. Write it down before you flip the switch. They are not the same, and this feature only moves one of them.

The general shape of the mistake

A feature ships. The feature is real. The feature demonstrably runs — you can see it in the log. And the number you care about doesn't move. The reflex is to conclude the feature is useless, or that the benchmark was flawed. Usually neither is true. Usually you parallelized a phase that wasn't the expensive one, and nobody checked which phase that was before turning the dial.

Two habits protect you. First, before enabling any concurrency feature, write down the fraction of total time the target phase consumes. If you can't state the fraction, you're guessing, and a guess is a bad reason to redistribute load on a production database. Second, after enabling it, verify that the setting you changed is the setting that controls the thing. The gap between the name of a config key and what it actually gates is where a great many "we tuned it and nothing happened" tickets are born.

The parallel workers launched. That part worked exactly as advertised. The next question — did the clock move — is the one worth asking before you ship the change.

Key Takeaways

  • PostgreSQL 19 beta4 can run autovacuum's index cleanup across parallel workers. In a 800,000-dead-tuple benchmark it cut the leader process's CPU by about 30% and total vacuum time by about 4% — inside run-to-run noise.
  • Parallelism is a multiplier on one phase, not on the job. Here the heap scan and its WAL output dominated every run, serial or parallel, and the feature doesn't touch it.
  • Measure the phase split before you enable the feature. If you can't say what percentage of the runtime index cleanup represents, you're guessing.
  • max_parallel_maintenance_workers does not gate autovacuum's new helpers; max_parallel_workers does. Get that wrong and you'll tune the wrong pool.
  • Parallel vacuum workers roughly doubled the dead-tuple TID store (1.45 MB to 3.00 MB). On big tables that memory multiplication, not CPU, is the cost to budget.
  • The setting degrades silently to zero workers when the global pool is full. If you aren't reading the autovacuum log, you won't know it never ran.

[ Call to action ]

Ready to scale your engineering team?

Tell us the roles you need to fill and we'll get back within 24 hours.