PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 19: Finding and fixing slow queries

Parallel query and JIT compilation

Level 4: Performance25 min

PostgreSQL can split one query across several worker processes and can compile expressions to machine code with LLVM. Both help large analytical queries and can hurt short OLTP queries, so knowing when each kicks in is part of tuning.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Read Gather, Gather Merge, Partial and Finalize nodes in a plan
  • Know which settings and function markings decide whether a query goes parallel
  • Recognise when JIT compilation costs more than it saves

conceptThe concept

A parallel plan has a Gather or Gather Merge node. Below it, the leader and up to N workers each run the same plan fragment over a share of the data (Parallel Seq Scan, Parallel Index Scan, Parallel Hash Join), and aggregates are split into Partial (per worker) and Finalize (in the leader).

SettingDefaultMeaning
max_worker_processes8Pool of all background workers (restart to change)
max_parallel_workers8How many of those may be parallel query workers
max_parallel_workers_per_gather2Workers one Gather node may request
min_parallel_table_scan_size8MBSmaller tables are not scanned in parallel
parallel_setup_cost, parallel_tuple_cost1000, 0.1What the planner charges for starting workers and moving rows to the leader

The plan says Workers Planned; EXPLAIN ANALYZE also says Workers Launched. If the pool is exhausted at run time, fewer workers start and the query silently runs slower. Queries do not go parallel when they write (except CREATE TABLE AS, CREATE INDEX, REFRESH MATERIALIZED VIEW), use a cursor, lock rows, or call a function marked PARALLEL UNSAFE (the default for user functions).

JIT compiles WHERE expressions, projections and tuple deforming to native code. It is on by default and triggers when the plan cost exceeds jit_above_cost (100000), with inlining and optimisation above 500000. EXPLAIN ANALYZE shows a JIT: block with the time spent compiling.

whyWhy it matters

A reporting query over 50 million rows can run four times faster with four workers. The same machinery can also starve OLTP traffic of CPU, and JIT can add 50 to 200 ms of compilation to a query that executes in 5 ms when a bad estimate pushes its cost over the threshold. Both show up as "the query is slow sometimes".

Use caseReal-world scenario

A mixed workload server keeps max_parallel_workers_per_gather = 2 globally, gives the analytics role 4 with ALTER ROLE analytics SET max_parallel_workers_per_gather = 4, and turns JIT off for the OLTP application role after seeing JIT blocks in auto_explain output for its sub-10 ms queries.

Try itExamples

Run and edit these in the interactive lesson.

Parallel and JIT settings here
SELECT name, setting
FROM pg_settings
WHERE name IN ('max_worker_processes', 'max_parallel_workers', 'max_parallel_workers_per_gather',
               'min_parallel_table_scan_size', 'parallel_setup_cost', 'jit', 'jit_above_cost')
ORDER BY name;
The browser build of PostgreSQL runs as a single process, so its worker settings are 0. A normal server ships with the defaults in the table above.
A parallel plan shape
BEGIN;
SET LOCAL max_parallel_workers_per_gather = 4;
SET LOCAL parallel_setup_cost = 0;
SET LOCAL parallel_tuple_cost = 0;
SET LOCAL min_parallel_table_scan_size = 0;
EXPLAIN (COSTS OFF)
SELECT kind, count(*) FROM events GROUP BY kind;
ROLLBACK;
Each worker aggregates its share (Partial), the leader merges the sorted partial results and finishes the count (Finalize).
Planned is not launched
BEGIN;
SET LOCAL max_parallel_workers_per_gather = 4;
SET LOCAL parallel_setup_cost = 0;
SET LOCAL parallel_tuple_cost = 0;
SET LOCAL min_parallel_table_scan_size = 0;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM events;
ROLLBACK;
This server has no worker processes, exactly like a busy server whose pool is used up: the leader does all the work alone. Alert on this pattern in auto_explain output.
Function parallel safety
SELECT DISTINCT proname, proparallel
FROM pg_proc
WHERE proname IN ('lower', 'now', 'random', 'nextval', 'setval')
ORDER BY proname;
nextval changes sequence state, so it is unsafe; random depends on per-process state, so it is restricted to the leader. Mark your own pure functions PARALLEL SAFE.

Over 200 ms was spent compiling. On an 800 ms query that may pay off; on a 20 ms query it is pure overhead.

JIT in a server plan
EXPLAIN (ANALYZE) SELECT sum(total * 1.2) FROM orders_archive WHERE created_at < '2024-01-01';

 Finalize Aggregate  (actual time=812.4..815.1 rows=1 loops=1)
   ->  Gather  (actual time=811.9..815.0 rows=3 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ...
 JIT:
   Functions: 11
   Options: Inlining true, Optimization true, Expressions true, Deforming true
   Timing: Generation 2.1 ms, Inlining 41.7 ms, Optimization 96.3 ms, Emission 70.8 ms, Total 210.9 ms
Tuning per role and per table
ALTER ROLE analytics SET max_parallel_workers_per_gather = 4;
ALTER ROLE app_oltp SET jit = off;
ALTER TABLE events SET (parallel_workers = 6);
CREATE FUNCTION net_price(p numeric) RETURNS numeric
  LANGUAGE sql IMMUTABLE PARALLEL SAFE
  RETURN round(p / 1.2, 2);

internalsUnder the hood

Workers are separate processes started through the background worker machinery; they attach to a dynamic shared memory segment with the plan, parameters and a snapshot, then return tuples to the leader through shared memory queues. The leader also executes the plan unless parallel_leader_participation = off. The planner picks the worker count from the table size: one more worker each time the table is three times larger than min_parallel_table_scan_size, capped by the per-gather setting or the table's parallel_workers storage parameter. Function safety is proparallel in pg_proc: s (safe), r (restricted, leader only) or u (unsafe).

Avoid / preferDo this, not that

Avoid
-- OLTP server, every query
SET max_parallel_workers_per_gather = 16;
Prefer
-- keep the global default small
ALTER ROLE analytics SET max_parallel_workers_per_gather = 4;

Each parallel query takes several CPU cores. On a busy OLTP server, a few parallel reports can push short transactions into CPU queues. Grant parallelism where it pays.

Avoid
CREATE FUNCTION tax(p numeric) RETURNS numeric LANGUAGE sql AS $$ SELECT p * 0.2 $$;
SELECT sum(tax(total)) FROM orders;  -- never parallel
Prefer
CREATE FUNCTION tax(p numeric) RETURNS numeric
  LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ SELECT p * 0.2 $$;

User functions default to PARALLEL UNSAFE, which disables parallelism for the whole query. Mark them SAFE only when they truly have no side effects.

PitfallsCommon mistakes

  • Raising max_parallel_workers_per_gather without raising max_worker_processes and max_parallel_workers, so workers are planned but not launched.
  • Marking a function that writes or uses sequences as PARALLEL SAFE, which can give wrong results or errors.
  • Blaming the query when an OLTP latency spike is really JIT compilation triggered by a bad row estimate.
  • Expecting INSERT ... SELECT or UPDATE to use parallel workers.

LimitsWhen not to use it

Turn parallelism down or off for latency-critical OLTP roles and for servers already CPU-bound; it spends more total CPU to finish one query sooner. Turn JIT off for workloads of short queries, where compilation time is rarely recovered.

SecuritySecurity notes

Parallel workers run as the same role with the same snapshot and privileges as the leader, so they do not change who can see what. The risk is resource exhaustion: an untrusted role running large parallel queries can starve others, so set per-role limits and statement_timeout for reporting users.

ChallengeTry it yourself

Show a parallel plan for total order value per order status, with the aggregation split into partial and final steps. Do not leave any setting changed.

Then try grouping by customer_id instead: with about 2000 groups the planner may decide partial aggregation is not worth it and send raw rows to the leader.

Show one solution
One solution
BEGIN;
SET LOCAL max_parallel_workers_per_gather = 2;
SET LOCAL parallel_setup_cost = 0;
SET LOCAL parallel_tuple_cost = 0;
SET LOCAL min_parallel_table_scan_size = 0;
EXPLAIN (COSTS OFF)
SELECT status, sum(total) FROM orders GROUP BY status;
ROLLBACK;

InterviewQuestions you should be able to answer

EXPLAIN ANALYZE shows Workers Planned: 4, Workers Launched: 1. What does that mean?
The planner costed the query for four workers, but at run time only one could be started because the max_parallel_workers or max_worker_processes pool was in use by other queries, autovacuum workers or logical replication. The leader and one worker did the work, so the query ran much slower than planned. Check concurrent parallel queries, raise the pools if CPU allows, or reduce per-gather parallelism for less important roles.
When does JIT hurt, and what do you do about it?
When compilation time is a large share of execution time: short queries whose estimated cost crosses jit_above_cost because of bad statistics, or many similar queries each compiling separately. You see a JIT block with hundreds of milliseconds of timing in EXPLAIN ANALYZE or auto_explain. Fix the estimate, raise jit_above_cost, or set jit = off for OLTP roles.
Why might a large aggregate never use parallel workers?
It calls a PARALLEL UNSAFE function (user functions are unsafe by default), it runs inside a cursor or a FOR UPDATE, it writes data, the table is below min_parallel_table_scan_size, max_parallel_workers_per_gather is 0 for the role, or the planner judged setup and tuple transfer costs too high for the row count.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation