Query planning & executionPro · lab evidenceSource-grounded1 real lab transcript

Parallel Query: How PostgreSQL Splits Work Across Workers

Historically each PostgreSQL query ran in a single backend process. Parallel query lets the planner split eligible work across multiple worker processes that execute parts of the plan concurrently, then combine results. The machinery lives in src/backend/executor/nodeGather.c, execParallel.c, and the parallel-aware scan nodes.

Grounded in the source

Where this comes from in postgres/postgres

Each citation was checked against the REL_17_STABLE branch: real file path, symbol present, exact line number. Follow any link to read the code.

src/backend/executor/nodeGather.cVerified · REL_17_STABLE

Primary symbol: ExecGather · line 137

The one thing to understand first

Historically each PostgreSQL query ran in a single backend process. Parallel query lets the planner split eligible work across multiple worker processes that execute parts of the plan concurrently, then combine results. The machinery lives in src/backend/executor/nodeGather.c, execParallel.c, and the parallel-aware scan nodes.

Parallelism is a cost-based decision, not a guarantee: the planner only goes parallel when the table is big enough to repay worker startup, and it can only launch as many workers as the shared pool has free. "Planned 4, launched 2" is the signature of a pool too small for your concurrency.

The Gather node

A parallel plan has a Gather (or Gather Merge) node. Below it is the parallel portion run by the leader plus its workers; above it is serial. At execution, the leader launches background workers, each runs its copy of the sub-plan, and produces tuples into a shared queue; Gather collects them. Gather Merge preserves sort order by merging each worker's ordered stream.

Reference
SQL
Gather  (workers planned: 4, launched: 4)
  ->  Parallel Seq Scan on big_table
        Filter: (status = 'active')

Parallel-aware scans divide the data

A Parallel Seq Scan is not four full scans — workers cooperatively claim blocks from a shared counter so each block is read once, in total. Similarly there are parallel index scans, parallel bitmap heap scans, parallel hash joins (workers build a shared hash table), and partial aggregates. The partial results are combined by a Finalize Aggregate above the Gather.

How the planner decides

Parallelism is costed like everything else. A table must be large enough to be worth the worker startup overhead, governed by:

  • min_parallel_table_scan_size / min_parallel_index_scan_size — size thresholds below which parallelism is not considered.
  • parallel_setup_cost and parallel_tuple_cost — model the overhead of launching workers and shipping tuples.
  • The number of workers scales with table size, capped by max_parallel_workers_per_gather.

The worker pools

Workers come from a shared pool with three nested limits:

  • max_worker_processes — total background workers for the whole cluster.
  • max_parallel_workers — how many of those may be used for parallel query.
  • max_parallel_workers_per_gather — cap per individual Gather node.

If the pool is exhausted, a plan plans for N workers but launches fewer — visible as workers planned: 4, launched: 2. Persistent under-launching means the pool is too small for the concurrency.

What blocks parallelism

  • Parallel-unsafe functions — a function marked PARALLEL UNSAFE (the default for functions that write or use sequences) forces a serial plan.
  • Writing queries — most data-modifying statements are not parallelised (with specific exceptions like parallel index build and some CTAS paths).
  • Cursors and certain locks can disable it.
  • max_parallel_workers_per_gather = 0 disables it entirely.

Layer 3 — Watch it happen on your own database

Lab-verified · corrected
SQL
EXPLAIN (ANALYZE, VERBOSE)
SELECT status, count(*) FROM big_table GROUP BY status;

-- Finalize Aggregate
--   -> Gather  (workers planned: 4, launched: 4)
--        -> Partial Aggregate
--             -> Parallel Seq Scan on big_table
Real captured output for this query is in the Pro lab notebook below.

The shape to recognise: a Gather with Partial Aggregate below it and Finalize Aggregate above. Compare workers planned to workers launched — if launched is consistently lower, the cluster's worker pool is exhausted under your real concurrency, and you are leaving parallelism on the table. Force a serial baseline with SET max_parallel_workers_per_gather = 0 to measure the actual speedup parallelism is buying.

Layer 4 — The levers this hands you

Lab-verified
SQL
-- Encourage parallelism for a reporting session
SET max_parallel_workers_per_gather = 8;
SET parallel_setup_cost  = 200;     -- lower if workers start cheaply
SET parallel_tuple_cost  = 0.01;

-- Mark a pure function as parallel-safe so it doesn't block plans
ALTER FUNCTION score(numeric) PARALLEL SAFE;
Real captured output for this query is in the Pro lab notebook below.
  • The three nested pool limitsmax_worker_processes (cluster total) ⊃ max_parallel_workersmax_parallel_workers_per_gather (per node). Size them to match real concurrency or you get under-launching.
  • Cost thresholdsmin_parallel_table_scan_size and parallel_setup_cost / parallel_tuple_cost decide when parallelism is worth it; lower them for analytics sessions.
  • Function safety labels — a PARALLEL UNSAFE function (the default for anything writing or using sequences) forces a serial plan; mark pure functions PARALLEL SAFE.
  • Writes are mostly serial — data-modifying statements (with narrow exceptions like parallel index build) won't parallelise.

Layer 5 — What an Oracle DBA should expect vs what they get

Oracle DBAs know parallel execution well, but the controls and defaults are different:

  • Conservative by default, not automatic. Oracle can auto-DOP whole statements (PARALLEL_DEGREE_POLICY=AUTO); PostgreSQL is deliberately cautious and parallelises only large scans/joins/aggregates that clear explicit size and cost thresholds.
  • Worker pool ≈ parallel_max_servers. The nested max_parallel_workers* limits play the role of Oracle's parallel server pool; "planned vs launched" is the Postgres equivalent of downgraded DOP.
  • **No table-level PARALLEL degree attribute.** You don't set a degree on the table; you tune session GUCs and let cost decide.
  • No hints. Where Oracle uses /*+ PARALLEL(t,8) */, Postgres uses SET max_parallel_workers_per_gather and the cost knobs — there is no per-query hint.

Key takeaway

A parallel plan puts a Gather/Gather Merge over parallel-aware scan and partial-aggregate nodes; workers cooperatively claim blocks so data is read once in total. The planner only goes parallel above size/cost thresholds, and can only launch workers the shared pool has free. Tune the three nested worker limits, the cost GUCs, and function safety labels — and watch workers launched to confirm you're actually getting the parallelism you planned.

Interview lens

How this shows up in interviews

What an interviewer is actually testing, the same mechanism seen from three roles, and the gap between a shallow answer and one that shows real depth.

What the interviewer is testing

Whether you know how and when Postgres parallelizes (a Gather over parallel workers) and what disqualifies a query.

Questions you should be able to answer

  • How does parallel query work — what are the Gather node and the workers?
  • What makes a query eligible or ineligible for parallelism?
  • Why might adding more workers not help?

Database engineer

You set max_parallel_workers_per_gather and size the worker pool, knowing each worker consumes its own work_mem allotment.

Software engineer

You know parallelism helps large scans and aggregates, not tiny OLTP queries, and that some constructs disable it.

Feature builder

You lean on parallelism for analytics and reporting features, not per-request OLTP paths.

Shallow answer

'Postgres runs the query on multiple cores.' No Gather/worker model and no eligibility conditions.

Answer that shows depth

The planner may place a Gather node over a parallel-aware subplan; the leader launches background workers (nodeGather/execParallel) that each execute a slice — a Parallel Seq Scan hands out block ranges, for example — and stream tuples back to the leader, which merges them. Eligibility depends on cost (the table must be big enough), parallel-safe functions, and settings like max_parallel_workers_per_gather. It shines for large scans, joins, and aggregates; it won't help small queries, it adds a work_mem allotment per worker, and parallel-unsafe functions or a writing CTE disable it.

Pro lab notebook

The captured run behind every query

Every SQL query above is real and the source citations are verified. Pro unlocks the captured psql output — each query re-run against a real PostgreSQL 17.10 lab, with its verbatim result.

Pro lab notebookTested, not just explained

1 real lab transcript for this lesson

The explanation and the SQL above are free — so are the verified source citations. What Pro unlocks is the proof: every query in Parallel Query: How PostgreSQL Splits Work Across Workers re-run against a real PostgreSQL 17.10 instance, with its verbatim psql output captured line for line.

What Pro unlocks here

  • Every query in this lesson re-run against a real PostgreSQL 17.10 lab
  • The verbatim psql output — page headers, row versions, plan nodes, catalog rows
  • The corrected form of any query the source lesson got wrong, re-captured
  • A copy-ready notebook you can replay yourself to confirm every claim
Unlock with Pro — $24.99/moor $199/yr — save ~$100

How this was verified

This explanation is anchored to PostgreSQL's source tree — the files linked above, on the REL_17_STABLE branch — and cross-checked against the official documentation for versions 15 through 18. Every SQL query shown was executed and its output captured in a throwaway PostgreSQL 17.10 lab; where a query needed correcting to run, that correction is noted inline.

Official documentation: Ch. 15 Parallel Query