ROLLUP — Hierarchical Subtotals
ROLLUP — Hierarchical Subtotals
ROLLUP is a shorthand for a specific, very common type of GROUPING SETS: a hierarchy of progressively collapsed groupings. If you have dimensions (a, b, c), then ROLLUP(a, b, c) automatically generates groupings for (a, b, c), (a, b), (a), and () — each step removing the rightmost dimension.
This maps perfectly to hierarchical data: year → quarter → month, country → region → city, category → subcategory → product. The hierarchy goes from left (coarsest) to right (finest), and ROLLUP produces every "rollup" of that hierarchy with a single keyword.
ROLLUP vs GROUPING SETS
-- Syntax:
-- GROUP BY ROLLUP(a, b, c)
-- is exactly equivalent to:
-- GROUP BY GROUPING SETS (
-- (a, b, c),
-- (a, b),
-- (a),
-- ()
-- )
ROLLUP(a, b, c) always assumes the hierarchy flows left to right. If your hierarchy is different (e.g. you want (a, c) but not (b, c)), use explicit GROUPING SETS instead.
Example 1: Sales hierarchy — Year → Quarter → Month
This is the classic use case. A finance dashboard needs revenue at every level of the time hierarchy: full monthly detail, quarterly totals, annual totals, and a grand total. One ROLLUP produces all four levels.
SELECT
EXTRACT(YEAR FROM created_at)::INT AS year,
EXTRACT(QUARTER FROM created_at)::INT AS quarter,
EXTRACT(MONTH FROM created_at)::INT AS month,
ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue,
COUNT(*) AS orders,
GROUPING(
EXTRACT(YEAR FROM created_at),
EXTRACT(QUARTER FROM created_at),
EXTRACT(MONTH FROM created_at)
) AS level_code
FROM orders o
JOIN ec_order_items oi ON oi.order_id = o.order_id
WHERE o.status = 'completed'
GROUP BY ROLLUP(
EXTRACT(YEAR FROM created_at),
EXTRACT(QUARTER FROM created_at),
EXTRACT(MONTH FROM created_at)
)
ORDER BY year NULLS LAST, quarter NULLS LAST, month NULLS LAST;
What This Returns
| year | quarter | month | revenue | level_code |
|---|---|---|---|---|
| 2024 | 1 | 1 | 45,200 | 0 — monthly detail |
| 2024 | 1 | 2 | 38,900 | 0 |
| 2024 | 1 | 3 | 52,100 | 0 |
| 2024 | 1 | NULL | 136,200 | 2 — quarterly subtotal |
| 2024 | NULL | NULL | 512,400 | 6 — annual subtotal |
| NULL | NULL | NULL | 1,024,800 | 7 — grand total |
The level_code increases as more dimensions are collapsed: 0 = most granular, 7 = grand total.
Example 2: Geographic hierarchy — Country → City
Using the invoicing dataset: compute total revenue at each level of the client geography hierarchy. This is the kind of rollup a regional VP would want — their city details, their country total, and the global total.
SELECT
CASE WHEN GROUPING(c.country) = 1 THEN 'GLOBAL TOTAL' ELSE c.country END AS country,
CASE WHEN GROUPING(c.country) = 1 THEN '(all cities)' ELSE c.country END AS city,
COUNT(DISTINCT c.client_id) AS clients,
COUNT(DISTINCT i.invoice_id) AS invoices,
ROUND(SUM(ii.quantity * ii.unit_price), 0) AS revenue,
ROUND((AVG(ii.quantity * ii.unit_price))::NUMERIC, 0) AS avg_item_value
FROM clients c
JOIN invoices i ON i.client_id = c.client_id
JOIN invoice_items ii ON ii.invoice_id = i.invoice_id
GROUP BY ROLLUP(c.country, c.country)
ORDER BY
GROUPING(c.country, c.country),
c.country NULLS LAST,
c.country NULLS LAST;
Note the ORDER BY GROUPING(c.country, c.country) as the first sort key — this ensures detail rows appear before subtotals and the grand total, regardless of alphabetical ordering.
Example 3: Product hierarchy with partial ROLLUP
Sometimes you want subtotals on only part of your GROUP BY. You can combine regular columns with ROLLUP to get fixed dimensions with a rollup on others:
-- Fixed dimension: year (always present)
-- Rollup dimension: category → subcategory → product
SELECT
EXTRACT(YEAR FROM DATE_TRUNC('day', o.created_at)::DATE)::INT AS year,
CASE WHEN GROUPING(p.category) = 1 THEN '(ALL)' ELSE p.category END AS category,
CASE WHEN GROUPING(p.subcategory) = 1 THEN '(ALL)' ELSE p.subcategory END AS subcategory,
COUNT(DISTINCT o.order_id) AS orders,
SUM(oi.quantity) AS units_sold,
ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue
FROM ec_orders o
JOIN ec_order_items oi ON oi.order_id = o.order_id
JOIN ec_products p ON p.product_id = oi.product_id
WHERE o.status = 'completed'
GROUP BY
EXTRACT(YEAR FROM DATE_TRUNC('day', o.created_at)::DATE),
ROLLUP(p.category, p.subcategory) -- rollup only product dimensions, year is fixed
ORDER BY year, GROUPING(p.category, p.subcategory), p.category NULLS LAST, p.subcategory NULLS LAST;
The year column is outside the ROLLUP, so every output row always has a year value. The rollup only happens within the product hierarchy. This gives you category/subcategory subtotals per year without a year-level rollup.
What This Returns
For each year, you get: subcategory detail rows (both category and subcategory set), category subtotals (subcategory NULL), and an all-products total (both NULL) — but never a cross-year grand total.
Example 4: ROLLUP for a management dashboard
A practical combined query that produces the full executive revenue dashboard — geographic hierarchy combined with product hierarchy — using nested rollups:
WITH revenue_base AS (
SELECT
c.country,
p.category,
DATE_TRUNC('month', DATE_TRUNC('day', o.created_at)::DATE)::DATE AS month,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders o
JOIN ec_order_items oi ON oi.order_id = o.order_id
JOIN ec_products p ON p.product_id = oi.product_id
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.status = 'completed'
GROUP BY c.country, p.category, 3
)
SELECT
CASE WHEN GROUPING(country) = 1 THEN 'ALL COUNTRIES' ELSE country END AS country,
CASE WHEN GROUPING(category) = 1 THEN 'ALL CATEGORIES' ELSE category END AS category,
CASE WHEN GROUPING(month) = 1 THEN NULL ELSE month END AS month,
ROUND(SUM(revenue), 0) AS revenue,
ROUND(100.0 * SUM(revenue) / SUM(SUM(revenue)) OVER (
PARTITION BY GROUPING(country, category, month)
), 1) AS pct_of_level
FROM revenue_base
GROUP BY ROLLUP(country, category, month)
ORDER BY GROUPING(country, category, month), country, category, month;
The pct_of_level column uses a window function over PARTITION BY GROUPING(...) — so detail rows show their percentage of the detail total, subtotal rows show their percentage of the subtotal total, and so on. This is a very powerful pattern for "what % of the total does this row represent at its level?"
Key Takeaway
ROLLUP is GROUPING SETS with the hierarchy assumption baked in. Use it whenever your dimensions have a natural left-to-right hierarchy (time, geography, product taxonomy). Combine with GROUPING() to label rows correctly, and with window functions to compute percentages-of-total at each level.