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:

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.

Two places parallelism can be lost A flow starting at the query. The first gate is planning: table size, cost thresholds and parallel safety decide whether a Gather node appears. The second gate is execution: the shared worker pool decides how many of the planned workers actually start. query planning gate table size · cost thresholds parallel-safe functions no cursor / no FOR UPDATE fails → no Gather node at all execution gate max_parallel_workers pool shared across all sessions first come, first served fails → Workers Launched < Planned the plan text tells you which gate you hit — check for a Gather node first

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 #

  1. Look for a Gather node. Absent means the planning gate; present with a shortfall means the execution gate.

  2. 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.

  3. 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'));
  4. 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.

  5. Raise the per-table worker count when a specific large table deserves more:

    ALTER TABLE events SET (parallel_workers = 8);
  6. Size the global pool against your CPU count and workload mix. max_parallel_workers must be less than or equal to max_worker_processes, and every worker consumes its own work_mem allowance — the memory arithmetic in tuning work_mem for hash aggregates applies per worker.

  7. Re-check Workers Launched under production concurrency. A number that is right at 3 a.m. and wrong at noon is a capacity finding, not a query finding.

The worker pool is shared, not per query A pool of eight worker slots. Three concurrent queries claim four, three and one slots, leaving none for a fourth query, which therefore launches zero workers and runs on its leader alone. max_parallel_workers = 8 query A — Workers Launched: 4 query B — Workers Launched: 3 query C — 1 query D — Workers Planned: 4, Launched: 0 pool exhausted → runs on the leader alone

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.

Where the rows go when workers do and do not launch With no workers, the leader processes all forty-eight million rows. With four workers launched, five participants each process about nine point six million rows. Launched: 0 leader — 48,000,000 rows · loops=1 Launched: 4 five participants × ~9,600,000 rows · loops=5 loops is the field that tells you which of these two actually happened

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.