Why Parallel Workers Are Not Launched #
A parallel plan is a promise made at planning time and honoured — or not — at execution time. The plan says Workers Planned: 4; the run says Workers Launched: 0. The query then executes exactly as a serial plan would, except that the planner costed it as though four workers would share the load, so its estimates are optimistic by roughly that factor.
This page separates the two distinct failures: parallelism never considered, and parallelism planned but not delivered. The mechanics of parallel execution are in parallel query execution.
Two Different Failures #
Never planned. The planner did not generate a parallel path at all. There is no Gather node in the plan. Causes, roughly in order of frequency:
- the relation is smaller than
min_parallel_table_scan_size(default 8 MB) or the index smaller thanmin_parallel_index_scan_size; max_parallel_workers_per_gatheris0;- the estimated cost does not clear
parallel_setup_costplusparallel_tuple_costoverheads; - the query contains a parallel-unsafe element: a function not declared
PARALLEL SAFE, a data-modifying statement, a cursor (DECLARE … CURSOR), or a locking clause such asFOR UPDATE.
Planned but not launched. There is a Gather, and Workers Launched is below Workers Planned. The cause is always resource availability: max_parallel_workers (a server-wide budget, itself capped by max_worker_processes) was already spent by other queries when this one started.
Annotated Evidence #
Pool exhaustion is the case that shows up under load and vanishes when you investigate:
EXPLAIN (ANALYZE)
SELECT count(*) FROM events WHERE payload_size > 4096;
Finalize Aggregate (actual time=18402.6..18402.6 rows=1 loops=1)
-> Gather (actual time=18402.4..18402.5 rows=1 loops=1)
Workers Planned: 4
Workers Launched: 0 -- the leader ran everything
-> Partial Aggregate (actual rows=1 loops=1)
-> Parallel Seq Scan on events (actual rows=48000000 loops=1)
Execution Time: 18402.9 ms
loops=1 on the parallel scan confirms only the leader participated. The same query on an idle system:
Workers Planned: 4
Workers Launched: 4
-> Parallel Seq Scan on events (actual rows=9600000 loops=5)
Execution Time: 4102.1 ms
loops=5 — four workers plus the leader — and roughly a quarter of the rows each. The 4.5× difference between the two runs is entirely worker availability, which is why intermittent slowness on analytics workloads so often traces back to this line.
The never-planned case looks different: no Gather at all.
-- A helper function that was never marked parallel safe
SELECT count(*) FROM events WHERE normalize_tag(tag) = 'urgent';
Aggregate (actual time=22104.8..22104.8 rows=1 loops=1)
-> Seq Scan on events (actual rows=48000000 loops=1)
Filter: (normalize_tag(tag) = 'urgent')
SELECT proname, proparallel FROM pg_proc WHERE proname = 'normalize_tag';
-- proname | proparallel
-- normalize_tag | u -- 'u' = unsafe, the default
One unmarked function disabled parallelism for the entire statement.
Step-by-Step Diagnosis #
-
Look for a
Gathernode. Absent means the planning gate; present with a shortfall means the execution gate. -
For the execution gate, check the budget against real concurrency:
SHOW max_parallel_workers; -- server-wide pool SHOW max_parallel_workers_per_gather; -- per Gather cap SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'parallel worker';Sample the last query during peak load, not while investigating quietly.
-
For the planning gate, check the size thresholds first:
SHOW min_parallel_table_scan_size; -- default 8MB SELECT pg_size_pretty(pg_relation_size('events')); -
Audit function safety for anything appearing in the query:
SELECT proname, proparallel FROM pg_proc WHERE proname IN ('normalize_tag','score_event'); ALTER FUNCTION normalize_tag(text) PARALLEL SAFE;Mark a function safe only when it neither writes to the database nor depends on session state.
-
Raise the per-table worker count when a specific large table deserves more:
ALTER TABLE events SET (parallel_workers = 8); -
Size the global pool against your CPU count and workload mix.
max_parallel_workersmust be less than or equal tomax_worker_processes, and every worker consumes its ownwork_memallowance — the memory arithmetic in tuning work_mem for hash aggregates applies per worker. -
Re-check
Workers Launchedunder production concurrency. A number that is right at 3 a.m. and wrong at noon is a capacity finding, not a query finding.
Before and After #
-- BEFORE: pool exhausted at peak
Gather Workers Planned: 4 Workers Launched: 0
-> Parallel Seq Scan on events (actual rows=48000000 loops=1)
Execution Time: 18402.9 ms
-- AFTER: max_parallel_workers raised from 8 to 16
Gather Workers Planned: 4 Workers Launched: 4
-> Parallel Seq Scan on events (actual rows=9600000 loops=5)
Execution Time: 4102.1 ms
loops is the honest indicator: 1 means the leader worked alone, 5 means four workers joined it.
Common Pitfalls #
Testing parallelism on an idle system. Workers are always available there. Diagnostic signal: a plan that cannot be reproduced during business hours. Fix: capture plans under real concurrency.
Trusting a plan’s cost after a launch shortfall. The estimate assumed the planned worker count. Diagnostic signal: actual time far above the estimate with Workers Launched: 0. Fix: read the launch line before comparing cost with time.
Leaving helper functions unmarked. PARALLEL UNSAFE is the default and it disables parallelism for the whole query. Diagnostic signal: proparallel = 'u' in pg_proc for a function in the predicate. Fix: mark genuinely safe functions PARALLEL SAFE.
Raising the pool without budgeting memory. Each worker claims its own work_mem for each memory-hungry node. Diagnostic signal: peak memory scaling with worker count. Fix: size work_mem against workers × memory nodes.
Expecting parallelism to help a selective query. Worker startup, tuple transfer through the Gather, and the final merge all cost something, and on a query returning a few hundred rows that overhead exceeds any sharing benefit. Diagnostic signal: a parallel plan measurably slower than the serial one on the same data. Fix: leave the cost thresholds alone rather than lowering parallel_setup_cost globally to force workers onto small queries.
Reducing parallel_tuple_cost to encourage parallelism. It prices moving rows from workers to the leader, and lowering it makes the planner underestimate the cost of returning large result sets through a Gather. Diagnostic signal: parallel plans chosen for queries that return millions of rows, with most of the time inside the Gather. Fix: adjust the table-level parallel_workers instead, which affects candidate paths without distorting the transfer cost.
Overlooking Gather Merge requirements. Preserving order across workers costs more than a plain Gather, and the planner will pick a serial path when that cost outweighs the sharing. Diagnostic signal: parallelism disappearing as soon as an ORDER BY is added. Fix: supply the ordering from an index so the merge is cheap, or accept the serial plan for ordered output.
Frequently Asked Questions #
What does Workers Launched: 0 mean?
The plan requested parallel workers but none were free when execution began, so the leader ran the entire plan. The usual cause is the server-wide max_parallel_workers pool being consumed by concurrent queries.
Why is a parallel plan never considered?
Typically the relation is below min_parallel_table_scan_size, max_parallel_workers_per_gather is zero, the estimated cost does not justify the setup overhead, or the query contains a parallel-unsafe function, a cursor, a locking clause, or a write.
How do I mark a function safe?
ALTER FUNCTION f(...) PARALLEL SAFE, but only when it neither writes to the database nor relies on session state. The default is PARALLEL UNSAFE, so one unmarked function silently serialises the query.
Related #
- Parallel Query Execution — parent guide: how parallel plans are built and read
- Parallel Worker Allocation Strategies — sibling: sizing the worker budget
- Reading & Interpreting Query Plans — section overview: interpreting loops, rows, and timings
- Reading Cost vs Actual Time in EXPLAIN ANALYZE — why the cost estimate misleads after a shortfall