GROUPING SETS, ROLLUP and MixedAggregate Plans #
A dashboard computes totals by region, by product, by region and product, and a grand total in one statement with GROUPING SETS. The plan shows a single MixedAggregate node with three Hash Key lines and one Group Key. It runs in 14 seconds; four separate GROUP BY queries take 5 seconds together. Multi-level grouping in one pass should be cheaper, and often is — but only when the planner can share work between the levels.
The basic choice between sorted and hashed grouping is described in GroupAggregate vs HashAggregate selection. Grouping sets combine several of those choices in one node.
The Planner Condition #
GROUPING SETS ((region), (product), (region, product), ()) asks for four groupings of the same input. ROLLUP (a, b, c) is shorthand for the prefixes (a,b,c), (a,b), (a), (); CUBE (a, b) for every subset. The planner chooses among:
- Sorted grouping (
GroupAggregate). One sort can serve every grouping set that is a prefix of the sort order.ROLLUP (region, product)needs one sort by(region, product)to produce(region, product),(region)and(). Sets that are not prefixes of one order —(product)alongside(region, product)— need an additional sort, shown as extraSort Keylines inside the node. - Hashed grouping (
HashAggregate). Each hashed grouping set builds its own hash table, shown asHash Key:lines. Memory is the sum of all hash tables. - Mixed (
MixedAggregate). Some sets hashed, some sorted, in one pass over the input. Chosen when some sets fit well in memory and others are cheaper as a sort-based rollup. The grand total()is always computed without a hash table.
The decision uses the estimated number of groups for each set, the hash memory budget, and whether the input already arrives sorted — from an index or a lower merge join. Poor group estimates for any one set can push the whole node into a bad mix.
Annotated EXPLAIN Evidence #
EXPLAIN (ANALYZE, BUFFERS)
SELECT region, product, sum(amount)
FROM sales
WHERE sold_on >= '2026-01-01'
GROUP BY GROUPING SETS ((region), (product), (region, product), ());
MixedAggregate (actual time=13904.2..14102.6 rows=412050 loops=1)
Hash Key: region
Hash Key: product
Group Key: region, product
Group Key: ()
Batches: 1 Memory Usage: 18424kB
-> Sort (actual time=11204.6..12910.8 rows=62040110 loops=1)
Sort Key: region, product
Sort Method: external merge Disk: 2104412kB -- 2 GB sort for one rollup chain
-> Seq Scan on sales (actual rows=62040110 loops=1)
Filter: (sold_on >= '2026-01-01'::date)
Execution Time: 14210.8 ms
-- the (region, product) set has 412k groups: planner chose to sort for it
-- the two single-column sets are hashed alongside in 18 MB
With enough hash memory for every set:
BEGIN;
SET LOCAL hash_mem_multiplier = 8;
EXPLAIN (ANALYZE, BUFFERS) <same query>;
ROLLBACK;
HashAggregate (actual time=6804.2..6980.4 rows=412050 loops=1)
Hash Key: region, product
Hash Key: region
Hash Key: product
Group Key: ()
Batches: 1 Memory Usage: 94402kB
-> Seq Scan on sales (actual rows=62040110 loops=1)
Execution Time: 7010.2 ms
-- no sort at all: every set hashed, 94 MB total
Step-by-Step Resolution #
-
List the strategy for each set from the
Hash KeyandGroup Keylines. Note everySortbeneath or inside the node. -
Check group estimates per set. Run the equivalent single
GROUP BYwithEXPLAINfor the largest set and comparerows=with reality; a large underestimate sends that set to hashing that later spills, an overestimate sends it to an expensive sort. -
Test with more hash memory for the transaction, as above. If every set fits, a pure
HashAggregateavoids sorts entirely — sized with hash_mem_multiplier and the hash memory budget. -
Or provide the sort order from an index so sorted sets are free:
CREATE INDEX CONCURRENTLY sales_sold_region_product_idx ON sales (region, product) WHERE sold_on >= '2026-01-01';A partial index is only reusable if the query’s range matches; otherwise index the full columns.
-
Prefer
ROLLUPshapes that nest along one order when the report allows it: one sort then serves every level. -
Precompute the finest grouping into a summary table and derive coarser levels from it with
GROUPING SETSover far fewer rows.
Before and After #
-- BEFORE: MixedAggregate with 2 GB external sort for (region, product)
MixedAggregate → Sort (external merge Disk: 2104412kB) → Seq Scan Execution Time: 14210.8 ms
-- AFTER: SET LOCAL hash_mem_multiplier = 8 for the dashboard transaction
HashAggregate Hash Key × 3 Batches: 1 Memory Usage: 94402kB → Seq Scan Execution Time: 7010.2 ms
Why separate queries were faster #
The four separate queries each chose their best strategy independently — three hash aggregates and a plain count — and three of them had tiny hash tables. The grouping-sets query instead committed to one node for all sets, and the planner judged the largest set’s hash table too big for the budget in force, so it paid for a full sort to serve it. Once the budget allowed all tables in memory, the single-pass plan beat the separate queries, because it read the 62 million input rows once instead of four times. The general lesson: grouping sets save input passes, but they bind all sets to one memory budget and one node. When one set is much larger than the others, give that node the memory it needs, or split that set into its own query.
The GROUPING() function helps tell levels apart in the output — GROUPING(region, product) returns a bitmask of which columns are aggregated away — and costs almost nothing to compute, so use it rather than inferring levels from nulls, which are ambiguous when the data itself contains nulls.
Common Pitfalls #
Assuming one pass means one cheap node. Each incompatible sort order is another full sort. Diagnostic signal: several Sort Key entries or a large sort beneath the node. Fix: nest sets as a rollup, add memory, or index the order.
Hash memory summed across sets. Every hashed set holds its table simultaneously. Diagnostic signal: Memory Usage equal to the sum of all group counts. Fix: budget the total, not the largest set.
Mistaking nulls for totals. Rows where a column is genuinely null look like subtotal rows. Diagnostic signal: double-counted subtotals in reports. Fix: use GROUPING() to identify levels.
CUBE over many columns. The number of sets doubles with each column. Diagnostic signal: dozens of Hash Key lines. Fix: request only the sets the report displays.
Frequently Asked Questions #
What is MixedAggregate in PostgreSQL? #
It is an aggregate node that computes some grouping sets with hash tables and others with a sort-based rollup, in a single pass over its input. The plan lists hashed sets as Hash Key lines and sorted sets as Group Key lines.
Are GROUPING SETS faster than separate GROUP BY queries? #
They read the input once instead of once per grouping, which usually wins. They can lose when one large set forces an expensive sort or spill because all sets share one node and one memory budget.
How many sorts does ROLLUP need? #
One, because every level of ROLLUP (a, b, c) is a prefix of the order (a, b, c). CUBE and arbitrary grouping sets need an additional sort for each order that is not a prefix of another.
Related #
- Aggregate and Grouping Strategies — parent guide: hash versus sort aggregation
- GroupAggregate vs HashAggregate Selection — sibling: the single-set decision
- Indexing Materialized Views for Dashboards — precomputing the finest grouping level