max_parallel_maintenance_workers is a ceiling, and most of the time something lower gets there first. It caps the number of parallel workers a single utility command may start. The default is 2, the context is user, and the range is 0 to 1024, with 0 turning parallel maintenance off. Raising it is easy. Getting a command to use what you raised it to is the harder problem.
It arrived in PostgreSQL 11 along with parallel B-tree builds. PostgreSQL 13 added parallel index vacuuming, 17 added BRIN builds, and 18 added GIN. Hash, GiST, and SP-GiST indexes still build serially, on 18 and on 19 beta 3.
Read “a single utility command” literally. This is a per-command cap, and there is no pool of maintenance workers behind it. I ran three index builds at once with the setting at 2 and had six workers alive, two under each leader. The total is bounded only by max_parallel_workers and max_worker_processes, and that post covers how those nest. (My 2023 post “Workers of the World, Unite!” described this parameter as the maximum number of maintenance workers that can exist at one time. That was wrong.)
What an index build asks for
An index build works out its worker request in three steps, and this parameter is only the middle one.
It starts with the same table-size ladder the planner uses for parallel scans: one worker at 8MB of heap, and one more each time the table triples. The ladder climbs slowly. With memory to spare, a 782MB table asked for five workers with this parameter at 8, and still five with it at 1024. Eight workers takes a 17GB table, and a terabyte gets you eleven. Until a table passes 4TB, setting this to 64 does nothing that setting it to 12 doesn’t.
This parameter then caps whatever the ladder produced.
Last, every participant has to be left with at least 32MB of maintenance_work_mem, and the leader counts as a participant. The maintenance_work_mem post has the measurements. The short version is that at the default 64MB an index build gets exactly one worker, however high this parameter goes, so on a stock install an index build cannot reach the default of 2. N workers need 32MB × (N + 1): 160MB for four, 224MB for six, 288MB for eight.
A table’s parallel_workers storage parameter skips the ladder and the memory floor, though not this cap. With it set to 6, this parameter at 8, and 64MB of memory, the build requested six workers, which leaves each participant about 9MB to sort in. The same storage parameter steers parallel query plans against the table, so set it for the build and reset it afterward.
What comes out of all this is a request. Whether the workers exist is up to the pool, and a shortfall is silent: the build runs with what it got.
Some builds never ask. Temporary tables always build serially. So does any index whose expression or predicate calls a function that isn’t marked PARALLEL SAFE, and since CREATE FUNCTION defaults to PARALLEL UNSAFE, that covers most people’s homemade IMMUTABLE helpers. (A simple SQL function that gets inlined escapes the check, which makes the behavior look random until you know the rule.)
Everything that builds an index takes this path, which is more than CREATE INDEX. REINDEX, both CONCURRENTLY forms (where only the first table scan is parallel), ALTER TABLE ... ADD PRIMARY KEY and ADD UNIQUE, and the index rebuilds at the end of CLUSTER and VACUUM FULL all requested workers on 18.6.
VACUUM counts indexes
VACUUM uses the parameter differently. Only the index phases run in parallel, the unit of work is a whole index, and the leader takes one index for itself. So the worker count is the number of indexes of at least min_parallel_index_scan_size (512kB), minus one, capped by the PARALLEL option if you gave one, and capped again by this parameter. On a table with four indexes, VACUUM (PARALLEL 8) at the default launches two.
There is no 32MB rule on this side, so a plain manual VACUUM of that same table launches two workers on a stock install without being asked. A table with one enormous index gets none at any setting, and the heap scan is never parallel.
Autovacuum ignores all of it. Through PostgreSQL 18, autovacuum never uses parallel workers, so this parameter reaches only the VACUUM you type (or that vacuumdb types for you). PostgreSQL 19 gives autovacuum parallel index vacuuming through a separate parameter, autovacuum_max_parallel_workers (context sighup, default 0), and the two don’t interact. On 19 beta 3, with this parameter at 0 and the new one at 2, autovacuum planned and launched two workers while a manual VACUUM (PARALLEL 4) ran serially.
Setting it, and checking that it took
The numbers I gave in 2023 hold up: 2 on a small machine, 4 with more than eight cores, 6 with a lot more than that. They need memory to be reachable, and the 256MB to 1GB global from the maintenance_work_mem post clears the floor for either. Because the context is user, the big one-off build should set both parameters in its own session and leave the globals alone.
Where this goes wrong is pg_restore -j. Every job is its own session running its own command, so four jobs at a setting of 4 can request sixteen workers from a pool that, at stock settings, has seven to give. In my test, four jobs requested three workers each, and the most I caught alive at once was seven, split three, three, and one. PGOPTIONS='-c max_parallel_maintenance_workers=2 -c maintenance_work_mem=1GB' in front of the pg_restore sets both for the restore’s sessions only. Pick the numbers so that jobs × (workers + 1) lands near your core count, and make sure max_parallel_workers covers jobs × workers. If that means raising max_worker_processes as well, that one takes a restart.
Then check. VACUUM (VERBOSE) just tells you: “launched 2 parallel vacuum workers for index vacuuming (planned: 2)”. Index builds are less forthcoming. SET client_min_messages = debug1 gets you a line saying the index is being built “with request for 5 parallel workers”, or “serially”, and that is the request; for what launched, count backend_type = 'parallel worker' rows in pg_stat_activity, grouped by leader_pid, while the build runs. Whatever you set, run one build that way and read the number. If it says 1, the limit you hit wasn’t this one.