Partial and Finalize Aggregate in Parallel Plans #
A GROUP BY customer_id over 400 million rows runs with four workers, but the plan shows Partial HashAggregate producing 380 million rows across the workers and Finalize HashAggregate in the leader doing almost all the real work. The query is barely faster than its serial version. Parallel aggregation only helps when the workers can shrink the data before it reaches the leader.
Aggregation strategies are covered in aggregate and grouping strategies; worker mechanics in parallel query execution. This page covers where the two meet.
The Planner Condition #
Parallel aggregation is a two-phase plan:
Partial Aggregate(orPartial HashAggregate/Partial GroupAggregate) runs in each worker and in the leader, aggregating only the rows that process scanned. It emits a transition state per group — foravg, a running sum and count — not a final value.GatherorGather Mergecollects those partial states.Finalize Aggregate(orFinalize HashAggregate/Finalize GroupAggregate) combines states for the same group using the aggregate’s combine function and computes final values.
Requirements:
- every aggregate in the query must be parallel-safe and have a combine function —
sum,count,avg,min,maxand most built-ins do;array_aggandstring_agggained parallel support in PostgreSQL 16; - no aggregate may use
DISTINCTorORDER BYin its arguments, as explained in COUNT(DISTINCT) plan costs; - user-defined aggregates need
COMBINEFUNCandPARALLEL SAFE.
When it pays: the benefit is proportional to how much each worker’s partial aggregate reduces its input. Grouping 400 million rows into 12 regions, each worker emits 12 small states and the leader combines 60 — near-linear speedup. Grouping into 380 million distinct customers, each worker emits almost one state per row, the Gather must transfer all of them, and the leader’s finalize step does a second full aggregation. The planner estimates this from the expected group count; with a poor estimate it can choose the split when it is useless.
Annotated EXPLAIN Evidence #
Few groups — the split works:
EXPLAIN (ANALYZE)
SELECT region, sum(amount) FROM sales GROUP BY region;
Finalize GroupAggregate (actual time=9204.1..9204.3 rows=12 loops=1)
Group Key: region
-> Gather Merge (actual time=9204.0..9204.2 rows=60 loops=1)
Workers Launched: 4
-> Sort (actual rows=12 loops=5)
Sort Key: region
-> Partial HashAggregate (actual time=9198.4..9198.5 rows=12 loops=5)
Group Key: region
-> Parallel Seq Scan on sales (actual rows=80008022 loops=5)
Execution Time: 9210.6 ms
-- 400M rows reduced to 12 states per process; leader combines 60 rows
Many groups — the split does not work:
EXPLAIN (ANALYZE)
SELECT customer_id, sum(amount) FROM sales GROUP BY customer_id;
Finalize HashAggregate (actual time=112804.2..121204.6 rows=380120004 loops=1)
Group Key: customer_id
Batches: 129 Memory Usage: 262193kB Disk Usage: 18104220kB
-> Gather (actual time=4.1..61204.2 rows=392004110 loops=1)
Workers Launched: 4
-> Partial HashAggregate (actual rows=78400822 loops=5)
Group Key: customer_id
Batches: 33 Disk Usage: 3204410kB
-> Parallel Seq Scan on sales (actual rows=80008022 loops=5)
Execution Time: 124102.6 ms
-- workers reduced 80M rows to 78M states each: almost no reduction
-- Gather moved 392M states; the leader re-aggregated them with 18 GB of spill
Step-by-Step Resolution #
-
Compute the reduction ratio:
Partial … rows ÷ scan rowsper loop. Below about 10% the split helps; near 100% it does not. -
Check the group estimate on the partial node against actual rows. An underestimate of distinct groups is what led the planner to split.
SELECT n_distinct FROM pg_stats WHERE tablename = 'sales' AND attname = 'customer_id';Correcting it — see overriding n_distinct on large tables — lets the planner cost the finalize step realistically.
-
Compare with a serial plan for high-cardinality groupings:
BEGIN; SET LOCAL max_parallel_workers_per_gather = 0; EXPLAIN (ANALYZE) SELECT customer_id, sum(amount) FROM sales GROUP BY customer_id; ROLLBACK; -
Reduce what flows through
Gatherby filtering early, aggregating on a narrower key, or pre-aggregating by a coarser column that the report can use. -
Check aggregate eligibility when no
Partialnode appears at all:SELECT a.aggfnoid::regproc, a.aggcombinefn, p.proparallel FROM pg_aggregate a JOIN pg_proc p ON p.oid = a.aggfnoid WHERE a.aggfnoid::regproc::text IN ('my_percentile', 'string_agg');aggcombinefn = '-'orproparallel = 'u'blocks the split. -
Size memory for both phases. Each worker’s partial hash table and the leader’s finalize table each use the hash memory budget.
Before and After #
-- BEFORE: parallel two-phase aggregation over 380M groups
Finalize HashAggregate (Disk Usage: 18104220kB) → Gather (392M rows) → Partial HashAggregate Execution Time: 124102.6 ms
-- AFTER: corrected n_distinct; planner chose parallel scan + single hash aggregate with larger budget
HashAggregate (Batches: 32) → Gather → Parallel Seq Scan Execution Time: 71840.2 ms
What the leader actually does #
The leader participates in the parallel phase by default (parallel_leader_participation = on): it scans and partially aggregates its own share while also running Gather. The finalize step then runs entirely in the leader after all partial states have arrived. That makes the finalize phase a serial bottleneck whose cost grows with the number of states — which is exactly why low-reduction groupings do not scale. Gather Merge with a Finalize GroupAggregate avoids a second hash table by merging sorted state streams, and the planner prefers it when the partial outputs are small enough to sort cheaply per worker, as in the region example. For high-cardinality keys, neither finalize strategy avoids handling hundreds of millions of states in one process; the only real fix is reducing the number of groups or accepting a serial plan with a well-sized hash budget.
Common Pitfalls #
Assuming parallel aggregation always scales. It depends on reduction. Diagnostic signal: partial output rows close to input rows. Fix: serial aggregation or a coarser grouping.
A single non-parallel aggregate blocking the split. One string_agg(… ORDER BY …) or custom aggregate without a combine function prevents it for the whole query. Diagnostic signal: no Partial nodes with an otherwise eligible scan. Fix: move that aggregate into a separate query or add a combine function.
Reading per-loop rows as totals. rows=12 loops=5 is 60 states. Diagnostic signal: finalize input apparently smaller than expected. Fix: multiply by loops.
Budgeting memory for one hash table. Workers and leader each build one. Diagnostic signal: memory spikes during parallel reports. Fix: budget (workers + 1) + 1 hash tables.
Frequently Asked Questions #
What is Finalize Aggregate in EXPLAIN? #
It is the second phase of parallel aggregation, running in the leader. It combines the partial transition states produced by workers for each group and computes the final aggregate values.
Why is there no Partial Aggregate in my parallel plan? #
At least one aggregate in the query is not parallel-safe, lacks a combine function, or uses DISTINCT or ORDER BY in its arguments. Any one of those prevents the two-phase split for the whole query.
Why is my parallel GROUP BY not faster? #
When the grouping key has nearly as many distinct values as rows, workers cannot shrink their output, so all states are transferred to the leader and aggregated again. Parallelism then adds transfer overhead without reducing the serial finalize work.
Related #
- Aggregate and Grouping Strategies — parent guide: aggregation node types
- HashAggregate Spill Diagnosis — sibling: spills in either phase
- Parallel Worker Allocation Strategies — sizing the workers that run the partial phase