open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 03 · 04

GROUPING SETS, ROLLUP, CUBE: many groupings, one pass

GROUPING SETS computes several groupings in one query; ROLLUP adds hierarchical subtotals, CUBE all combinations. GROUPING() tells a subtotal NULL from a real one.

SQL Senior ◷ 15 min
Level
FoundationsJuniorMiddleSenior

A finance team wanted one table: revenue per status, revenue per user, and the grand total — all at once. The naive build was three SELECTs glued with UNION ALL, scanning orders three times and drifting out of sync whenever someone edited one branch. One GROUP BY ROLLUP replaced all of it: a single scan, subtotals and grand total included, impossible to desync. This is the feature that turns a reporting query from a maintenance liability into one line.

One query, several groupings

Ask yourself: how many times have you seen a dashboard built from three near-identical queries glued with UNION ALL, each scanning the same table and drifting out of sync the moment someone fixes a bug in one branch? GROUPING SETS is the answer.

Normally a GROUP BY produces exactly one grouping. GROUPING SETS lets you ask for several groupings in a single query, stacking their results into one output:

SELECT status, user_id, SUM(total) AS revenue
FROM orders
GROUP BY GROUPING SETS (
  (status),        -- revenue per status
  (user_id),       -- revenue per user
  ()               -- grand total (empty set = no grouping)
);

Each parenthesized list is one grouping. The empty set () means “don’t group at all” — the grand total over every row. Postgres scans orders once and emits the union of all three groupings. Columns not part of a given grouping come back as NULL in those rows (the user_id is NULL in the per-status rows, because user isn’t in that set).

ROLLUP and CUBE: shorthands for common shapes

Two shorthands cover the patterns you actually want:

  • ROLLUP (a, b) expands to the hierarchical grouping sets (a, b), (a), (). Use it when columns form a hierarchy — (year, month) → totals per month, subtotal per year, grand total. It’s “drill-down with subtotals.”
  • CUBE (a, b) expands to all combinations: (a, b), (a), (b), (). Use it for a full cross-tab where every dimension and the totals matter.
-- Subtotals per status within the rollup hierarchy, plus a grand total:
SELECT status, SUM(total) AS revenue
FROM orders
GROUP BY ROLLUP (status);   -- = GROUPING SETS ((status), ())

-- Every combination of status × user_id, plus all subtotals & grand total:
SELECT status, user_id, SUM(total)
FROM orders
GROUP BY CUBE (status, user_id);

ROLLUP (a, b) produces n+1 grouping sets; CUBE of n columns produces 2^n. Reach for ROLLUP for hierarchies (the common 90%) and CUBE only when you genuinely need every cross-combination — its output grows exponentially with dimensions.

The reason this beats the old UNION ALL pattern is the scan count. On a 12M-row orders table, computing per-status, per-user, and grand-total as three SELECTs glued with UNION ALL is three independent Seq Scans — ~3 × 800ms ≈ 2.4s plus the cost of re-reading 36M rows total. The GROUPING SETS form EXPLAIN ANALYZEs as a single Seq Scan (rows=12000000) feeding a MixedAggregate (or GroupAggregate over a sorted input) that computes all three groupings from one pass — ~950ms, one read of 12M rows. CUBE’s catch is the output: CUBE over 4 dimensions is 2⁴ = 16 grouping sets, and on high-cardinality columns each set can be millions of rows — the result can dwarf the base table. Postgres still does it in one input scan, but the aggregation state for 16 simultaneous groupings can blow work_mem and spill (HashAggregate ... Disk Usage), so add dimensions to a CUBE deliberately.

GROUPING(): is this NULL a subtotal or real data?

Here’s the trap. A subtotal row has NULL in the columns it’s not grouped by. But your data might also contain a genuine NULL status. How do you tell “all statuses” (subtotal) from “the order whose status is actually NULL”? The GROUPING() function:

SELECT
  status,
  SUM(total) AS revenue,
  GROUPING(status) AS is_subtotal   -- 1 = subtotal row, 0 = real status value
FROM orders
GROUP BY ROLLUP (status);

GROUPING(col) returns 1 if col was rolled up (aggregated away) for that row — i.e. this is a subtotal/total row — and 0 if col is a real grouped value (including a genuine NULL). Use it to label rows or to coalesce cleanly: CASE WHEN GROUPING(status) = 1 THEN 'All statuses' ELSE status END. Without GROUPING(), a real NULL status and a subtotal are indistinguishable, and your report silently double-labels them.

Edge cases

GROUPING() can take multiple arguments and returns a bitmask: GROUPING(a, b) yields a 2-bit integer where each bit says whether that column was rolled up. For CUBE(a, b), GROUPING(a, b) returns 0 (both real), 1 (b rolled up), 2 (a rolled up), or 3 (grand total). That bitmask is the clean way to sort or filter to a specific grouping level in a single CUBE result — e.g. HAVING GROUPING(a, b) = 0 keeps only the fully-detailed rows. Yes, you can put GROUPING() in HAVING — it’s an aggregate-context expression.

Quiz

What does GROUP BY ROLLUP (status) compute that a plain GROUP BY status does not?

Quiz

In a ROLLUP result, a row has status = NULL. How do you tell a subtotal row from a genuinely NULL status?

Complete the analogy

Fill in the blank: ROLLUP (a, b) expands to the hierarchical grouping sets (a,b), (a), () — whereas CUBE (a, b) expands to _______ combinations, including (b) on its own.

Recall before you leave
  1. 01
    What problem do GROUPING SETS / ROLLUP / CUBE solve, and what would you write without them?
  2. 02
    How do ROLLUP (a,b) and CUBE (a,b) differ in the grouping sets they expand to?
  3. 03
    Why is GROUPING() necessary, and what does it return?
Recap

GROUPING SETS computes several groupings in one query and one table scan, stacking their result rows — the empty set () being the grand total. It replaces the old pattern of UNION ALL-ing one query per grouping, which rescanned the table and drifted out of sync. ROLLUP (a, b) is the hierarchical shorthand for (a,b), (a), () — drill-down subtotals plus grand total — and CUBE is the exhaustive 2^n shorthand for every combination, to be used sparingly because it grows exponentially. The subtlety is NULLs: a subtotal row carries NULL in its rolled-up columns, indistinguishable from a real NULL value, so GROUPING(col) (1 for a rolled-up/subtotal row, 0 for a real value, a bitmask for multiple columns) is how you label, sort, or filter rows correctly. Next we apply the conditional-aggregation tools you’ve learned to build pivot tables — turning long rows into wide report columns with SUM(CASE …) and FILTER.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.