skip to content

GROUPING SETS, ROLLUP, and CUBE

One query can produce several grouping levels at once — subtotals and grand totals — via GROUPING SETS, with ROLLUP and CUBE as shorthands. Interviewers for data-heavy roles use these to separate candidates who write reporting SQL from those who UNION five queries together.

part ofSQLoverview, primer and where to startread it →
on this pageshow

questions

5

In a ROLLUP result, how do you tell a subtotal row's NULL from a NULL in the data?

level: middleimportance: must knowfreq 50%

answer

  1. The value alone cannot tell you
  2. There is a function for this
  3. Returns 1 or 0 per grouped row
  4. 1 means rolled away, 0 means grouped
  5. GROUPING() also works in HAVING and ORDER BY

basics

~10 s

Use the GROUPING() function: GROUPING(region) returns 1 when that row's grouping set left region out, and 0 when the row is genuinely grouped by region — including when the grouped value itself is NULL.

solid answer

~50 s

You cannot tell them apart by looking at the value: a super-aggregate row and a real group whose key is NULL both show NULL in that column. The standard answer is `GROUPING(expr)`, which returns **1** when the expression was rolled away for that row and **0** when the row really is grouped by it. So `CASE WHEN GROUPING(region) = 1 THEN 'All regions' ELSE COALESCE(region, '(unknown)') END` labels totals correctly and still distinguishes a genuine NULL region. `GROUPING()` is usable in `SELECT`, `HAVING` and `ORDER BY`, so it also drives filtering (`HAVING GROUPING(region) = 0`) and sorting (`ORDER BY GROUPING(region), region`). The common bug is `COALESCE(region, 'Total')`, which mislabels the real NULL group as a total. PostgreSQL's multi-argument `GROUPING(a, b)` and Oracle's and SQL Server's `GROUPING_ID(a, b)` return a bit mask covering several columns at once.

code

sql · 11 lines
sql
-- WRONG: 'All regions' is printed twice when some rows have region IS NULL
SELECT COALESCE(region, 'All regions') AS region_label, SUM(amount) AS total
FROM sales
GROUP BY ROLLUP(region);

-- RIGHT: test GROUPING() first, then handle the data NULL separately
SELECT CASE WHEN GROUPING(region) = 1 THEN 'All regions'
            ELSE COALESCE(region, '(no region)') END AS region_label,
       SUM(amount) AS total
FROM sales
GROUP BY ROLLUP(region);

go deeper

for a junior

Remember that both a real NULL key and a subtotal placeholder show up as NULL, and that GROUPING(col) returning 1 marks the placeholder. Recognising the COALESCE mislabelling bug is the expected takeaway.

for a middle

Explain the 1/0 contract precisely, write the CASE label expression, and say why filtering on IS NOT NULL is not equivalent to filtering on GROUPING() = 0.

for a senior

Demonstrate using GROUPING() in HAVING and ORDER BY to control which levels reach the report and where subtotals sort, and discuss returning a level indicator so the client never re-derives it from values.

for a principal

Frame it as a contract between the query and the reporting layer: totals must be self-describing in the result set, and NOT NULL grouping keys or explicit sentinels remove a whole class of reporting defects at the schema level.

## Two different NULLs in one column A grouping-set query can put two kinds of NULL in the same output column: - a **data NULL** — the group really exists, and its grouping key is NULL (rows whose `region` was never filled in collapse into one group whose key is NULL); - a **placeholder NULL** — the row is a super-aggregate produced by a grouping set that excluded that column, so there is no single value to show. They are indistinguishable by value. Any presentation logic that reads the column alone (`COALESCE`, `IFNULL`, a client-side `if (region == null)`) will confuse them, and the symptom is a report with two rows labelled "Total" whose numbers do not match. ## GROUPING() The SQL standard provides `GROUPING(expression)` for exactly this. Its argument must be one of the expressions in the `GROUP BY` clause. It returns: - **1** if that expression is *not* part of the grouping set that produced this row (the value shown is a placeholder); - **0** if it *is* part of the grouping set (the value shown is a real grouping key, NULL or not). ```sql SELECT region, GROUPING(region) AS is_total, SUM(amount) AS total FROM sales GROUP BY ROLLUP(region); ``` For a table with regions `EU`, `US` and some rows with no region at all, this returns `EU/0`, `US/0`, `NULL/0` (the real no-region group) and `NULL/1` (the grand total). The `is_total` column is the only thing separating the last two. ## Labelling The correct label expression tests `GROUPING()` first and only then handles a data NULL: ```sql SELECT CASE WHEN GROUPING(region) = 1 THEN 'All regions' ELSE COALESCE(region, '(no region)') END AS region_label, SUM(amount) AS total FROM sales GROUP BY ROLLUP(region); ``` Writing `COALESCE(region, 'All regions')` instead is the classic bug: it prints "All regions" twice, once for the genuine NULL-region group and once for the grand total. ## Filtering and sorting with it `GROUPING()` is not restricted to the select list. Because it is evaluated per grouped row, it is legal in `HAVING` and in `ORDER BY`: - `HAVING GROUPING(region) = 0` keeps only rows that are grouped by region — a precise way to drop the grand total that, unlike `region IS NOT NULL`, does not also drop the legitimate NULL-region group. - `ORDER BY GROUPING(region), region, GROUPING(product), product` sorts each subtotal after its own detail block, independently of how the engine orders NULLs. That beats relying on `NULLS LAST`, which would also move a data NULL. ## Several columns at once When a query rolls up three or four columns, testing each one separately gets noisy. Engines offer a bit-mask form: PostgreSQL accepts multi-argument `GROUPING(region, product)`, and Oracle and SQL Server offer `GROUPING_ID(region, product)`. Both return an integer whose bits correspond to the arguments, the leftmost argument being the most significant bit: `0` = both columns grouped (a detail row), `1` = only the last argument rolled away, `3` = both rolled away (the grand total). That single integer is convenient as a "level" column for the reporting client to switch on. Which spelling you get is engine-specific, so check the documentation rather than assuming. ## Why the engine cannot just do it for you SQL has no distinct "total marker" value; the standard chose to reuse NULL for the placeholder and to add a function that exposes what the value alone cannot. That design means the *information* is always available, but only if the query asks for it. If your reporting layer needs to know which level a row belongs to, the query must return `GROUPING()` (or the bit mask) as a column — it cannot be reconstructed afterwards from the values. ## Practical rules Select a `GROUPING()` column for every column you roll up when the base data can contain NULLs in those columns; add a `NOT NULL` constraint on grouping keys where the domain allows, which removes the ambiguity at the source; and never let a client infer "this is a total row" from a NULL value alone.

  • Why is HAVING region IS NOT NULL not a safe way to drop ROLLUP total rows?
    Because it also drops any legitimate group whose region value is genuinely NULL, silently removing real data from the report. `HAVING GROUPING(region) = 0` tests the row's grouping set instead of its value, so it removes only the super-aggregate rows and keeps the NULL-region group intact.
  • What does the multi-argument form of GROUPING return?
    A bit mask. PostgreSQL's `GROUPING(region, product)` and Oracle's and SQL Server's `GROUPING_ID(region, product)` return an integer whose bits correspond to the arguments, leftmost most significant: 0 when both are grouped, 1 when only `product` was rolled away, 3 for the grand total. It is a compact level indicator for the client to switch on.
  • Where can GROUPING() legally appear in a query?
    Anywhere an aggregate may appear in a grouped query: the select list, `HAVING`, and `ORDER BY`. Its argument must be an expression from the `GROUP BY` clause — `GROUPING(some_ungrouped_column)` is an error. It is meaningless in `WHERE`, which runs before grouping happens.
  • How would you avoid the ambiguity entirely?
    Make the grouping columns `NOT NULL` where the domain allows it, or map missing values to an explicit sentinel such as 'UNKNOWN' during load. Then any NULL in a grouping column of the result is unambiguously a super-aggregate placeholder. That is a schema decision, though; `GROUPING()` remains the correct answer for data you do not control.

saying these in an interview costs you the question

  • Says COALESCE(region, 'Total') is the standard solution
  • Claims a subtotal NULL is recognisable by its position in the result
  • Thinks GROUPING() returns the number of rows in the group
  • Uses region IS NOT NULL to filter out super-aggregate rows
  • Believes engines emit a special total marker instead of NULL

context

open as a page

What extra rows does GROUP BY ROLLUP(region, product) add to a normal grouped result?

level: juniorimportance: should knowfreq 40%

basics

~10 s

It adds one subtotal row per region plus one grand-total row on top of the ordinary region-and-product groups. In those extra rows the columns rolled away come back as NULL placeholders.

open as a page

How do GROUP BY ROLLUP(a, b, c) and GROUP BY CUBE(a, b, c) differ in the grouping sets they generate?

level: middleimportance: should knowfreq 44%

basics

~20 s

ROLLUP generates the n+1 prefixes of the column list, so four grouping sets for three columns. CUBE generates every subset — 2^n, or eight for three columns — including combinations that skip a leading column.

open as a page

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

level: middleimportance: should knowfreq 38%

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.

open as a page

In a ROLLUP report, why doesn't the grand-total AVG equal the average of the subtotal AVGs?

level: seniorimportance: nice to knowfreq 30%

basics

~20 s

Each grouping set is computed independently from the base rows, so the grand-total AVG is the sum of all values over the count of all rows. Averaging the subtotal averages instead weights every group equally.

open as a page