skip to content

How would you return per-region, per-product and overall totals in one query using GROUPING SETS?

level: middleimportance: should knowfreq 38%

answer

  1. One statement instead of three branches
  2. List each level explicitly
  3. Inner parentheses hold one level's keys
  4. The empty pair of parentheses is the grand total
  5. Missing columns come back NULL in each level

basics

~10 s

Write GROUP BY GROUPING SETS ((region), (product), ()) with SUM in the select list. Each listed set becomes its own aggregation level in one result, and the empty set () yields the grand total.

solid answer

~50 s

`GROUP BY GROUPING SETS ((region), (product), ())` asks for exactly three aggregation levels in a single statement: totals per region, totals per product, and one overall row from the empty grouping set `()`. The select list is shared by all levels, so a column not in a given grouping set comes back as a NULL placeholder — the per-product rows have `region = NULL` and vice versa, which is the same padding you would have written by hand in a `UNION ALL`. Compared with three UNION ALL branches you get one statement, one shared `WHERE` clause, one pass over the filtered rows instead of three, and no risk of the branches drifting apart when a filter changes. GROUPING SETS is the general construct; `ROLLUP` and `CUBE` are just shorthands for particular lists. Add `GROUPING(region)`/`GROUPING(product)` columns so the consumer can tell which level each row belongs to.

code

sql · 11 lines
sql
-- one statement, three aggregation levels
SELECT region, product, SUM(amount) AS total
FROM sales
WHERE order_date >= DATE '2026-01-01'
GROUP BY GROUPING SETS ((region), (product), ());

-- EU   | NULL | 30   <- per region
-- US   | NULL | 30
-- NULL | A    | 40   <- per product
-- NULL | B    | 20
-- NULL | NULL | 60   <- grand total from ()

go deeper

for a junior

Be able to read the clause: each inner parenthesised list is one aggregation level, () is the overall total, and columns outside a level's keys come back NULL in that level's rows.

for a middle

Write the query from a requirement, explain the NULL padding against the UNION ALL equivalent, and say why ROLLUP and CUBE are just named lists of grouping sets.

for a senior

Argue the maintenance and single-pass benefits over UNION ALL, return level flags so consumers never guess, control ordering explicitly, and name the engines where the construct is unavailable.

for a principal

Own the reporting interface: which levels the query contract guarantees, how the client identifies them, and whether portability constraints push you back to UNION ALL or to a pre-aggregated summary.

## The construct `GROUPING SETS` takes an explicit list of grouping-key lists and produces the union of the results of grouping by each of them: ```sql SELECT region, product, SUM(amount) AS total FROM sales WHERE order_date >= DATE '2026-01-01' GROUP BY GROUPING SETS ((region), (product), ()); ``` Three sets, three levels in one result: one row per region, one row per product, and one grand-total row. Each inner parenthesised list is one level; `()` — the empty grouping set — means "no grouping keys at all", which puts every qualifying row into a single group, exactly as an aggregate query with no `GROUP BY` would. ## The shared select list and its NULL padding All levels share one select list, so every level must supply a value for every output column. A level that does not group by `product` returns NULL there. That is not an accident of implementation; it is the same padding you would write manually in the UNION version: ```sql SELECT region, CAST(NULL AS varchar) AS product, SUM(amount) FROM sales GROUP BY region UNION ALL SELECT NULL, product, SUM(amount) FROM sales GROUP BY product UNION ALL SELECT NULL, NULL, SUM(amount) FROM sales; ``` Seeing the two side by side makes the trade obvious. The UNION version repeats the table reference and the `WHERE` clause three times — three chances for them to drift when someone adds a filter — and reads the input once per branch. The GROUPING SETS version states the filter once and lets the engine compute all levels together. ## Multiple columns inside one set Inner lists may hold several columns: `GROUPING SETS ((region, product), (region), ())` groups by the pair, then by region alone, then overall — which is precisely `ROLLUP(region, product)`. That is the relationship between the three constructs: `GROUPING SETS` is the general form, `ROLLUP` and `CUBE` are abbreviations for the prefix list and the power set respectively. When you want an arbitrary handful of levels — say `(region, product)`, `(product)` and `()` but not `(region)` — only the explicit list can express it. ## Duplicates and the empty set A grouping set list is taken literally. Listing the same set twice produces its rows twice; nothing deduplicates them. Omitting `()` means no grand total at all — a frequent oversight, because `ROLLUP` and `CUBE` always include it and people assume `GROUPING SETS` does too. `GROUPING SETS (())` on its own is legal and returns exactly one row, the overall aggregate. ## Telling the levels apart Because the levels share a shape, the consumer needs to know which level each row came from. Add the flags: ```sql SELECT region, product, GROUPING(region) AS no_region, GROUPING(product) AS no_product, SUM(amount) AS total FROM sales GROUP BY GROUPING SETS ((region), (product), ()); ``` Now `no_region = 1, no_product = 0` marks a per-product row unambiguously, even if some sales rows genuinely have a NULL region. Sorting matters too: results have no defined order, and a report usually wants `ORDER BY GROUPING(region), region, GROUPING(product), product` or an explicit level column so the totals land where a reader expects them. ## Where each clause applies `WHERE` is evaluated before grouping and so applies identically to every level — one of the main benefits over the UNION rewrite. `HAVING` is evaluated per grouped row and can therefore filter individual levels, for example `HAVING GROUPING(region) = 1 OR SUM(amount) > 1000`. Aggregate values are always computed from the base rows for that level, never from the rows of another level. ## Portability `GROUPING SETS` is standard SQL and is supported by PostgreSQL, Oracle, SQL Server and DB2. MySQL 8 does not offer it (only `WITH ROLLUP`), and SQLite does not either, so on those engines the `UNION ALL` rewrite remains the portable formulation — worth remembering before a query written for one engine is promised to another. Check your engine's documentation rather than assuming.

  • What does the empty grouping set () mean, and what happens if you leave it out?
    `()` means grouping by nothing: every qualifying row falls into one group, producing the grand total — the same single row an aggregate query with no GROUP BY returns. If you omit it, the result simply has no overall row. ROLLUP and CUBE always include it implicitly, which is why people forget it is optional in an explicit GROUPING SETS list.
  • Beyond fewer keystrokes, what do you actually gain over the UNION ALL rewrite?
    One place to state the filter and the join, so branches cannot drift apart; one pass over the filtered input instead of one per level; and a single result set whose levels are distinguishable via GROUPING(). You also avoid manually casting the NULL padding columns to the right types, which the UNION form often needs to keep the branches union-compatible.
  • Can you express GROUPING SETS levels that ROLLUP and CUBE cannot?
    Yes — that is the point of the general form. `GROUPING SETS ((region, product), (product), ())` gives a detail level, a per-product level and a grand total but no per-region level; neither a prefix expansion nor a power set produces exactly that list. Use the explicit form whenever you want an arbitrary subset of levels rather than all of them.

saying these in an interview costs you the question

  • Thinks GROUPING SETS deduplicates rows across levels
  • Forgets () and then wonders where the grand total went
  • Expects a column outside a level's keys to hold a value
  • Believes the levels are computed from each other rather than the base rows
  • Assumes the result comes back grouped by level without ORDER BY

context