SQL - ROLLUP with Examples

ROLLUP is a GROUP BY extension that generates subtotals and grand totals in a single query. Instead of writing multiple queries and combining them with UNION ALL, you get hierarchical aggregations...

Key Insights

  • ROLLUP generates hierarchical subtotals and grand totals automatically, eliminating the need for multiple queries or UNION ALL statements to build summary reports.
  • Column order in ROLLUP matters—it processes from right to left, creating subtotals for each level of your hierarchy (year → quarter → month, not the reverse).
  • Use GROUPING() functions to distinguish between actual NULL values in your data and the NULL placeholders that ROLLUP inserts for subtotal rows.

Introduction to ROLLUP

ROLLUP is a GROUP BY extension that generates subtotals and grand totals in a single query. Instead of writing multiple queries and combining them with UNION ALL, you get hierarchical aggregations automatically.

Consider a sales report. You want totals by region, then a grand total across all regions. Without ROLLUP, you’d write two queries and union them together. With ROLLUP, it’s one query that produces both levels.

The key concept is hierarchy. ROLLUP assumes your columns form a drill-down path: continent → country → city, or year → quarter → month. It produces subtotals at each level of that hierarchy, rolling up from the most granular to the most aggregate.

Use ROLLUP when your grouping columns have a natural parent-child relationship. If you need all possible combinations of subtotals (not just hierarchical ones), you’ll want CUBE instead—but that’s a different tool for a different job.

Basic Syntax and How ROLLUP Works

The syntax is straightforward:

SELECT column1, column2, aggregate_function(column3)
FROM table_name
GROUP BY ROLLUP(column1, column2);

ROLLUP creates grouping sets from right to left. For ROLLUP(a, b, c), you get:

  1. Groups by (a, b, c) — most detailed
  2. Groups by (a, b) — subtotal for each a, b combination
  3. Groups by (a) — subtotal for each a
  4. Groups by () — grand total

Here’s a concrete example. Suppose you have a sales table:

CREATE TABLE sales (
    region VARCHAR(50),
    amount DECIMAL(10, 2)
);

INSERT INTO sales VALUES
('North', 1500.00),
('North', 2300.00),
('South', 1800.00),
('South', 2100.00),
('East', 1200.00),
('West', 2800.00);

A basic ROLLUP on region:

SELECT 
    region,
    SUM(amount) AS total_sales
FROM sales
GROUP BY ROLLUP(region);

Result:

region total_sales
East 1200.00
North 3800.00
South 3900.00
West 2800.00
NULL 11700.00

The NULL in the region column indicates the grand total row. That single query replaced what would have been a regular GROUP BY plus a separate total query.

Multi-Column ROLLUP

When you add multiple columns to ROLLUP, the hierarchy becomes more interesting. Column order determines the subtotal structure.

CREATE TABLE detailed_sales (
    region VARCHAR(50),
    category VARCHAR(50),
    amount DECIMAL(10, 2)
);

INSERT INTO detailed_sales VALUES
('North', 'Electronics', 1500.00),
('North', 'Electronics', 800.00),
('North', 'Clothing', 600.00),
('South', 'Electronics', 2100.00),
('South', 'Clothing', 900.00),
('South', 'Clothing', 400.00);

Now ROLLUP with two columns:

SELECT 
    region,
    category,
    SUM(amount) AS total_sales
FROM detailed_sales
GROUP BY ROLLUP(region, category)
ORDER BY region NULLS LAST, category NULLS LAST;

Result:

region category total_sales
North Clothing 600.00
North Electronics 2300.00
North NULL 2900.00
South Clothing 1300.00
South Electronics 2100.00
South NULL 3400.00
NULL NULL 6300.00

You get three levels: detail rows (region + category), region subtotals (region + NULL), and the grand total (NULL + NULL).

Swap the column order to ROLLUP(category, region) and you’d get category subtotals instead of region subtotals. The first column in ROLLUP is the outermost grouping level.

Partial ROLLUP

Sometimes you want subtotals for only part of your grouping. Partial ROLLUP lets you mix standard GROUP BY columns with ROLLUP columns.

CREATE TABLE time_sales (
    year INT,
    quarter INT,
    month INT,
    amount DECIMAL(10, 2)
);

INSERT INTO time_sales VALUES
(2024, 1, 1, 5000.00),
(2024, 1, 2, 5500.00),
(2024, 1, 3, 4800.00),
(2024, 2, 4, 6200.00),
(2024, 2, 5, 5900.00),
(2024, 2, 6, 6100.00);

To get subtotals within the time hierarchy but always grouped by year:

SELECT 
    year,
    quarter,
    month,
    SUM(amount) AS total_sales
FROM time_sales
GROUP BY year, ROLLUP(quarter, month)
ORDER BY year, quarter NULLS LAST, month NULLS LAST;

Result:

year quarter month total_sales
2024 1 1 5000.00
2024 1 2 5500.00
2024 1 3 4800.00
2024 1 NULL 15300.00
2024 2 4 6200.00
2024 2 5 5900.00
2024 2 6 6100.00
2024 2 NULL 18200.00
2024 NULL NULL 33500.00

Notice there’s no row where year is NULL. The year column stays in standard GROUP BY, so you get quarterly subtotals and a yearly total, but no grand total across all years.

Using GROUPING() and GROUPING_ID()

NULL values in ROLLUP output are placeholders meaning “all values.” But what if your data contains actual NULLs? The GROUPING() function solves this ambiguity.

GROUPING(column) returns 1 if the column is aggregated (a subtotal row), 0 if it’s a regular grouped value:

SELECT 
    CASE WHEN GROUPING(region) = 1 THEN 'All Regions' ELSE region END AS region,
    CASE WHEN GROUPING(category) = 1 THEN 'All Categories' ELSE category END AS category,
    SUM(amount) AS total_sales,
    GROUPING(region) AS region_is_subtotal,
    GROUPING(category) AS category_is_subtotal
FROM detailed_sales
GROUP BY ROLLUP(region, category)
ORDER BY GROUPING(region), region, GROUPING(category), category;

Result:

region category total_sales region_is_subtotal category_is_subtotal
North Clothing 600.00 0 0
North Electronics 2300.00 0 0
North All Categories 2900.00 0 1
South Clothing 1300.00 0 0
South Electronics 2100.00 0 0
South All Categories 3400.00 0 1
All Regions All Categories 6300.00 1 1

GROUPING_ID() combines multiple GROUPING() results into a single integer, useful for filtering or sorting:

SELECT 
    region,
    category,
    SUM(amount) AS total_sales,
    GROUPING_ID(region, category) AS grouping_level
FROM detailed_sales
GROUP BY ROLLUP(region, category);

The grouping_level values: 0 = detail, 1 = region subtotal, 3 = grand total. It’s a bitmap where each bit represents whether that column is aggregated.

ROLLUP vs. CUBE vs. GROUPING SETS

Three related operators, three different use cases:

  • ROLLUP: Hierarchical subtotals (drill-down path)
  • CUBE: All possible subtotal combinations
  • GROUPING SETS: Explicit control over which groupings to include

Here’s the same data with all three:

-- ROLLUP: hierarchical (region → category)
SELECT region, category, SUM(amount)
FROM detailed_sales
GROUP BY ROLLUP(region, category);
-- Produces: (region, category), (region), ()

-- CUBE: all combinations
SELECT region, category, SUM(amount)
FROM detailed_sales
GROUP BY CUBE(region, category);
-- Produces: (region, category), (region), (category), ()

-- GROUPING SETS: explicit control
SELECT region, category, SUM(amount)
FROM detailed_sales
GROUP BY GROUPING SETS(
    (region, category),
    (region),
    ()
);
-- Produces exactly what you specify

CUBE adds a subtotal by category alone—something ROLLUP doesn’t provide because category isn’t a parent of region in the hierarchy. Use CUBE when dimensions are independent (like analyzing sales by both region and product line without a hierarchy). Use GROUPING SETS when you need specific combinations that don’t fit either pattern.

Practical Use Cases

Here’s a complete financial report query that combines everything:

CREATE TABLE financial_data (
    fiscal_year INT,
    quarter INT,
    department VARCHAR(50),
    expense_type VARCHAR(50),
    amount DECIMAL(12, 2)
);

INSERT INTO financial_data VALUES
(2024, 1, 'Engineering', 'Salaries', 150000.00),
(2024, 1, 'Engineering', 'Equipment', 25000.00),
(2024, 1, 'Marketing', 'Salaries', 80000.00),
(2024, 1, 'Marketing', 'Advertising', 45000.00),
(2024, 2, 'Engineering', 'Salaries', 155000.00),
(2024, 2, 'Engineering', 'Equipment', 15000.00),
(2024, 2, 'Marketing', 'Salaries', 82000.00),
(2024, 2, 'Marketing', 'Advertising', 52000.00);

SELECT 
    fiscal_year,
    CASE 
        WHEN GROUPING(quarter) = 1 THEN 'Annual'
        ELSE 'Q' || quarter::VARCHAR
    END AS period,
    CASE 
        WHEN GROUPING(department) = 1 THEN 'COMPANY TOTAL'
        ELSE department
    END AS department,
    CASE 
        WHEN GROUPING(expense_type) = 1 THEN 'Subtotal'
        ELSE expense_type
    END AS expense_type,
    TO_CHAR(SUM(amount), 'FM$999,999,999.00') AS total_amount,
    CASE GROUPING_ID(quarter, department, expense_type)
        WHEN 0 THEN 'Detail'
        WHEN 1 THEN 'Dept Quarter Total'
        WHEN 3 THEN 'Quarter Total'
        WHEN 7 THEN 'Grand Total'
        ELSE 'Subtotal'
    END AS row_type
FROM financial_data
GROUP BY fiscal_year, ROLLUP(quarter, department, expense_type)
ORDER BY 
    fiscal_year,
    GROUPING(quarter), quarter,
    GROUPING(department), department,
    GROUPING(expense_type), expense_type;

This produces a report with quarterly breakdowns by department and expense type, quarterly totals, and an annual grand total—all from one query. The GROUPING_ID helps downstream applications identify row types for formatting or filtering.

ROLLUP shines in reporting scenarios: financial statements, inventory aging reports, time-series analytics with period comparisons, and any hierarchical summary where you need multiple aggregation levels without multiple queries.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.