11 min read

PostgreSQL Parallel Query: Workers, Costs, and When It Backfires

Parallel query makes big scans faster until forty concurrent queries all want workers at once. The knobs, the cost model, Gather vs Gather Merge, and why Workers Launched says zero.

Parallel query is one of those PostgreSQL features that looks like pure upside in the release notes and behaves like a resource-allocation problem in production. A sequential scan split across four workers really does finish several times faster — the first time you try it, alone, on an idle benchmark box. Then the same plan launches during the Tuesday batch window alongside forty other queries that each want their own workers, and suddenly nothing is parallel and everything is contended. I have tuned this on analytics-heavy instances and on mixed OLTP systems, and the lessons are consistent: know what the planner is pricing, know what the pool actually contains, and know why "Workers Launched: 0" shows up in your EXPLAIN.

What PostgreSQL actually parallelizes

Parallel query is fundamentally about scans. The leader process divides a sequential scan's pages among worker processes; each worker scans its share and pushes rows up to a Gather node, which merges the streams so the leader can finish the plan — usually a final aggregation or sort built on partial results each worker computed. Since PostgreSQL 10 and 11 the machinery grew to cover parallel bitmap heap scans, parallel btree index scans, parallel hash joins, and parallel append over partitions. Parallel btree scan is the sleeper feature: a big index range scan for a reporting query divides leaf pages among workers and behaves a lot like a parallel sequential scan without reading the whole heap.

What never parallelizes is equally important: the write half of DML — though CREATE TABLE AS, SELECT INTO, and CREATE or REFRESH MATERIALIZED VIEW can run their underlying SELECT in parallel — the leader's own work above the Gather, and any query the planner considers cheap. Parallelism is a tax you pay to make big scans faster. If there is no big scan, there is nothing to buy, only overhead to pay.

The knobs, in the order you should touch them

max_parallel_workers_per_gather (default 2) caps how many workers a single Gather node may request. It is the knob you raise per query or per role when a specific report deserves more of the machine. max_parallel_workers (default 8) is the cluster-wide pool for query parallelism, carved out of max_worker_processes (also default 8) — and that second default is the trap: max_worker_processes also funds background workers, logical replication, and parallel maintenance, so on a small instance the pool is smaller than it looks. max_parallel_maintenance_workers (default 2) caps how many of those pooled workers a single utility command — CREATE INDEX or parallel VACUUM — may start. It is a per-command limit, not a separate budget: maintenance workers come out of the same max_parallel_workers pool your queries use, so a big index build and your queries really are sharing one pie.

-- Give one heavyweight report role more rope, not the whole instance:
ALTER ROLE reporting SET max_parallel_workers_per_gather = 4;

-- Per statement, for one-off forensics:
SET max_parallel_workers_per_gather = 4;
EXPLAIN (ANALYZE, BUFFERS)
SELECT date_trunc('day', created_at) AS day, count(*)
FROM events
GROUP BY 1
ORDER BY 1;

How the planner prices parallelism

The planner does not know your machine is idle; it knows costs. parallel_setup_cost (default 1000) prices the process-spawning overhead, and parallel_tuple_cost (default 0.1) prices each row's trip through the shared-memory queue from worker to leader. Below min_parallel_table_scan_size (default 8MB) and min_parallel_index_scan_size (default 512kB), parallelism is not even considered. Raise the cost settings and you get parallelism only on truly enormous scans; lower them and everything goes parallel, which is how benchmarking blog posts mislead people into bad defaults.

The pricing has a real blind spot you should know about: the cost model assumes workers make roughly linear progress, but every worker still contends for the same disks and the same buffer pool. A four-worker scan on already-saturated storage is four processes fighting over the same IOPS, and the speedup can be negative. This is why I tune parallel costs based on observed runtimes during real traffic windows, not during the quiet hour when every experiment looks like a win.

Gather versus Gather Merge

EXPLAIN shows one of two collection nodes. Gather concatenates worker output in whatever order it arrives — fine for aggregates and unordered results. Gather Merge appears when the plan must preserve ordering, typically because an ORDER BY or a merge join sits above it; each worker produces a sorted stream and the leader merges them. Gather Merge is not a problem in itself, but seeing it means the leader is doing a k-way merge single-threaded, and on very wide sorts that merge can become the bottleneck you did not parallelize away. When a plan shows Gather Merge with most of the runtime in the leader, check whether the sort belongs in the workers or whether the query wants a different shape entirely.

Why Workers Launched is zero

The single most-asked parallel question, answered. EXPLAIN ANALYZE prints both "Workers Planned" and "Workers Launched". When launched is less than planned — often zero — the cause is usually pool exhaustion: other queries already hold the workers, because max_parallel_workers is 8 and your batch job has five queries each holding two. The query then runs serially, and because the plan was costed for parallelism it did not get, the runtime is worse than if parallelism had never been considered. Less common causes: max_worker_processes set too low relative to demand, a big parallel CREATE INDEX holding several of the pool, or a client that fetches results in chunks — an Execute with a non-zero fetch count over the extended query protocol forces the leader to run the parallel portion alone, which is how cursor-based reporting tools quietly serialize every parallel plan they touch.

The operational fix is sizing honesty. The number of workers your instance can actually deliver is max_parallel_workers — shared with any parallel maintenance running at the same time — divided across the queries that run concurrently at peak. If the honest answer is "about one worker per query at peak," then a per-gather limit of 2 is already optimistic at peak, and the right move is per-role settings: the one report that matters gets workers, and the OLTP traffic stays serial by default. Per-role and per-database settings beat global ones almost every time on mixed workloads.

When parallelism actively hurts

Three situations I now treat as defaults, not exceptions. First, high-concurrency OLTP: hundreds of tiny queries gaining nothing from workers while the setup cost and queue overhead are real. Second, memory pressure: work_mem applies per plan node per worker, so a parallel hash aggregate with four workers can allocate several times the work_mem you budgeted. Third, CPU oversubscription on small instances, where eight planned workers on four cores timeslice themselves into looking slower than one honest process. In all three, the answer is the same — keep parallelism for workloads that scan gigabytes, and let latency-sensitive traffic run serial. The memory math deserves its own reading; our memory tuning guide covers the work_mem-per-node multiplication in detail, and the comparison pages show how other engines answer the same question.

Watching parallelism with MonPG

The gap between workers planned and workers launched is the capacity signal most dashboards never show. MonPG's whole job today is monitoring PostgreSQL in production, and it keeps query-level runtime statistics and plan history that put that gap next to the instance's worker-pool pressure. When a report's runtime doubles because it silently lost its workers at peak, you see the plan change and the concurrency that caused it together, instead of diffing EXPLAIN output from two terminals. Start from the slow query monitoring view, and the PostgreSQL monitoring overview shows the rest of the picture.