Wicked Smart Data
LearnArticlesAbout
Sign InSign Up
LearnArticlesAboutContact
Sign InSign Up
Wicked Smart Data

The go-to platform for professionals who want to master data, automation, and AI — from Excel fundamentals to cutting-edge machine learning.

Platform

  • Learning Paths
  • Articles
  • About
  • Contact

Connect

  • Contact Us
  • RSS Feed

© 2026 Wicked Smart Data. All rights reserved.

Privacy PolicyTerms of Service
All Articles
Multi-Level Aggregation with ROLLUP, CUBE, and GROUPING SETS: Building Summary Reports and Cross-Dimensional Totals

Multi-Level Aggregation with ROLLUP, CUBE, and GROUPING SETS: Building Summary Reports and Cross-Dimensional Totals

SQL⚡ Practitioner24 min readJul 26, 2026Updated Jul 26, 2026
Table of Contents
  • Introduction
  • Prerequisites
  • Setting Up the Dataset
  • ROLLUP: Hierarchical Subtotals Along a Chain
  • Your First ROLLUP
  • Three-Level ROLLUP: Region > Country > Product Line
  • Partial ROLLUP: Mixing Fixed and Rolled-Up Dimensions
  • CUBE: Every Possible Combination of Subtotals
  • A Three-Dimensional CUBE
  • When to Use CUBE vs ROLLUP
  • GROUPING SETS: Surgical Precision Over Aggregation Levels
  • Replacing UNION ALL with GROUPING SETS
  • A Real-World Use Case: Non-Hierarchical Multi-Level Report
  • The GROUPING() Function: Distinguishing Real NULLs from Aggregation NULLs
  • Using GROUPING() to Label Rows Clearly
  • GROUPING_ID(): Numeric Grouping Level Identifier
  • Combining and Nesting Grouping Operators
  • Concatenating GROUPING SETS
  • Expressing ROLLUP and CUBE Using GROUPING SETS
  • Partial CUBE and Mixed Operators
  • Performance Considerations
  • Single Pass vs. Multiple Passes
  • Indexes and ROLLUP
  • When to Pre-Aggregate
  • Hands-On Exercise: Build a Complete Executive Sales Report
  • Common Mistakes & Troubleshooting
  • Mistake 1: Treating All NULLs as Missing Data
  • Mistake 2: Putting Non-Aggregated Columns in SELECT Without Grouping Them
  • Mistake 3: Ordering Without Accounting for NULLs
  • Mistake 4: Using CUBE When ROLLUP or GROUPING SETS Would Do
  • Mistake 5: Forgetting Parentheses in GROUPING SETS
  • Mistake 6: Conflating ROLLUP Column Order with GROUPING SETS Behavior
  • Database Compatibility Notes
  • Summary & Next Steps
  • What to Practice Next
  • Multi-Level Aggregation with ROLLUP, CUBE, and GROUPING SETS: Building Summary Reports and Cross-Dimensional Totals

    Introduction

    Picture this: your VP of Sales drops a request on your desk at 4 PM on a Friday. She needs a report that shows total revenue broken down by region, then by region and product category, then by product category alone, and finally a company-wide grand total — all in a single result set that can feed into an executive dashboard. Your first instinct might be to write four separate queries and UNION them together. That works, but it's brittle, verbose, and slow. Every time someone adds a new dimension to the report, you're rewriting significant chunks of SQL.

    This is exactly the problem that ROLLUP, CUBE, and GROUPING SETS were designed to solve. These extensions to the GROUP BY clause let you compute multiple levels of aggregation in a single query pass, producing the subtotals, cross-dimensional totals, and grand totals that executive reports demand. They're part of the SQL standard and supported in PostgreSQL, SQL Server, MySQL 8+, BigQuery, Snowflake, and most other major databases.

    By the end of this lesson, you'll understand not just the syntax but the logic behind each operator — when to reach for which one, how to handle the NULLs they introduce, how to nest and combine them, and how they perform at scale. You'll build a complete multi-level sales summary report from scratch.

    What you'll learn:

    • How ROLLUP produces hierarchical subtotals along a defined dimension chain
    • How CUBE generates every possible combination of subtotals across multiple dimensions
    • How GROUPING SETS gives you precise, surgical control over which aggregation levels appear
    • How to use GROUPING() and GROUPING_ID() to distinguish real NULLs from aggregation NULLs
    • How to combine these operators, nest them, and apply them to realistic reporting scenarios

    Prerequisites

    You should be comfortable with standard GROUP BY aggregations, window functions, and CTEs. You don't need to have used ROLLUP or CUBE before, but you should understand what SUM(), COUNT(), and AVG() do in a grouped context. Basic familiarity with CASE expressions will help in the GROUPING() section.


    Setting Up the Dataset

    Throughout this lesson we'll work with a sales dataset that's realistic enough to be interesting. Let's create it:

    -- Create the sales table
    CREATE TABLE sales (
        sale_id       INT PRIMARY KEY,
        sale_date     DATE,
        region        VARCHAR(50),
        country       VARCHAR(50),
        product_line  VARCHAR(50),
        product_name  VARCHAR(100),
        channel       VARCHAR(50),
        units_sold    INT,
        unit_price    NUMERIC(10,2),
        unit_cost     NUMERIC(10,2)
    );
    
    -- Insert representative data
    INSERT INTO sales VALUES
    (1,  '2024-01-15', 'North America', 'USA',     'Hardware', 'Laptop Pro',     'Online',    12, 1299.00, 780.00),
    (2,  '2024-01-18', 'North America', 'Canada',  'Hardware', 'Laptop Pro',     'Retail',     5, 1299.00, 780.00),
    (3,  '2024-01-22', 'North America', 'USA',     'Software', 'Analytics Suite','Online',    30,  299.00,  45.00),
    (4,  '2024-02-03', 'Europe',        'Germany', 'Hardware', 'Laptop Pro',     'Retail',     8, 1350.00, 780.00),
    (5,  '2024-02-07', 'Europe',        'France',  'Software', 'Analytics Suite','Online',    22,  299.00,  45.00),
    (6,  '2024-02-14', 'Europe',        'Germany', 'Software', 'Dev Tools',      'Online',    18,  199.00,  30.00),
    (7,  '2024-03-01', 'APAC',          'Japan',   'Hardware', 'Tablet X',       'Online',    25,  649.00, 310.00),
    (8,  '2024-03-05', 'APAC',          'Australia','Software','Analytics Suite','Retail',    10,  299.00,  45.00),
    (9,  '2024-03-12', 'APAC',          'Japan',   'Hardware', 'Laptop Pro',     'Online',     6, 1299.00, 780.00),
    (10, '2024-04-02', 'North America', 'USA',     'Hardware', 'Tablet X',       'Retail',    20,  649.00, 310.00),
    (11, '2024-04-09', 'North America', 'Canada',  'Software', 'Dev Tools',      'Online',    35,  199.00,  30.00),
    (12, '2024-04-15', 'Europe',        'France',  'Hardware', 'Tablet X',       'Online',    14,  649.00, 310.00),
    (13, '2024-05-01', 'APAC',          'Australia','Hardware','Tablet X',       'Online',    18,  649.00, 310.00),
    (14, '2024-05-10', 'North America', 'USA',     'Software', 'Dev Tools',      'Online',    50,  199.00,  30.00),
    (15, '2024-05-22', 'Europe',        'Germany', 'Hardware', 'Tablet X',       'Retail',    11,  649.00, 310.00);
    
    -- A computed revenue column will be handy
    -- revenue = units_sold * unit_price
    -- margin  = units_sold * (unit_price - unit_cost)
    

    This gives us three regions, multiple countries, two product lines, three products, and two channels — enough dimensionality to make the aggregation operators genuinely useful.


    ROLLUP: Hierarchical Subtotals Along a Chain

    ROLLUP is the right tool when your dimensions have a natural hierarchy. Region contains countries. Year contains quarters contains months. Product line contains products. ROLLUP(a, b, c) produces aggregations for:

    • (a, b, c) — the full detail level
    • (a, b) — subtotal collapsing c
    • (a) — subtotal collapsing b and c
    • () — the grand total

    That's n + 1 grouping levels for n columns. The order of columns in ROLLUP determines the hierarchy, which is why it matters deeply.

    Your First ROLLUP

    Let's start with a simple two-level rollup: region and product line.

    SELECT
        region,
        product_line,
        SUM(units_sold * unit_price)          AS total_revenue,
        SUM(units_sold * (unit_price - unit_cost)) AS total_margin
    FROM sales
    GROUP BY ROLLUP(region, product_line)
    ORDER BY region NULLS LAST, product_line NULLS LAST;
    

    The result looks like this (abbreviated):

    region          | product_line | total_revenue | total_margin
    ----------------|--------------|---------------|-------------
    APAC            | Hardware     |    51,769.00  |   22,869.00
    APAC            | Software     |     5,980.00  |    5,030.00
    APAC            | NULL         |    57,749.00  |   27,899.00   ← APAC subtotal
    Europe          | Hardware     |    51,006.00  |   21,146.00
    Europe          | Software     |     9,956.00  |    8,516.00
    Europe          | NULL         |    60,962.00  |   29,662.00   ← Europe subtotal
    North America   | Hardware     |    46,927.00  |   23,497.00
    North America   | Software     |    25,547.00  |   22,127.00
    North America   | NULL         |    72,474.00  |   45,624.00   ← NA subtotal
    NULL            | NULL         |   191,185.00  |  103,185.00   ← Grand total
    

    Notice that ROLLUP introduces NULLs in the grouping columns to represent "this level has been collapsed." That last row — where both region and product_line are NULL — is the grand total.

    Three-Level ROLLUP: Region > Country > Product Line

    Now let's use the full hierarchy:

    SELECT
        region,
        country,
        product_line,
        SUM(units_sold * unit_price)               AS total_revenue,
        COUNT(DISTINCT sale_id)                    AS transaction_count
    FROM sales
    GROUP BY ROLLUP(region, country, product_line)
    ORDER BY 
        region   NULLS LAST,
        country  NULLS LAST,
        product_line NULLS LAST;
    

    This produces four levels:

    1. (region, country, product_line) — individual combinations
    2. (region, country) — per-country subtotals
    3. (region) — per-region subtotals
    4. () — grand total

    Key insight: ROLLUP is directional. ROLLUP(region, country) is NOT the same as ROLLUP(country, region). The first gives you region-level subtotals; the second gives you country-level subtotals. Always order your columns from highest to lowest in the hierarchy.

    Partial ROLLUP: Mixing Fixed and Rolled-Up Dimensions

    Sometimes you want to fix one dimension and roll up across others. This is where ROLLUP inside a GROUP BY alongside regular columns becomes useful:

    -- Keep channel fixed; roll up within region/product_line
    SELECT
        channel,
        region,
        product_line,
        SUM(units_sold * unit_price) AS total_revenue
    FROM sales
    GROUP BY channel, ROLLUP(region, product_line)
    ORDER BY channel, region NULLS LAST, product_line NULLS LAST;
    

    Here, channel is always present in every row. The rollup only applies to region and product_line. You'll get per-channel grand totals and per-channel, per-region subtotals — but never a cross-channel total.


    CUBE: Every Possible Combination of Subtotals

    ROLLUP assumes a hierarchy. CUBE assumes nothing — it generates subtotals for every possible subset of the grouping columns. For n columns, CUBE generates 2^n grouping combinations.

    With three columns (region, product_line, channel), CUBE produces:

    • (region, product_line, channel) — full detail
    • (region, product_line) — by region and product line
    • (region, channel) — by region and channel
    • (product_line, channel) — by product line and channel
    • (region) — by region alone
    • (product_line) — by product line alone
    • (channel) — by channel alone
    • () — grand total

    That's 8 grouping sets from 3 columns. At 4 columns it's 16. At 5 it's 32. This is both CUBE's superpower and its danger.

    A Three-Dimensional CUBE

    SELECT
        region,
        product_line,
        channel,
        SUM(units_sold * unit_price)               AS total_revenue,
        SUM(units_sold * (unit_price - unit_cost)) AS total_margin,
        COUNT(*)                                   AS row_count
    FROM sales
    GROUP BY CUBE(region, product_line, channel)
    ORDER BY 
        region       NULLS LAST,
        product_line NULLS LAST,
        channel      NULLS LAST;
    

    The output will have significantly more rows than a simple GROUP BY. You'll see rows like:

    region  | product_line | channel | total_revenue | ...
    --------|--------------|---------|---------------|----
    APAC    | Hardware     | Online  |   37,819.00   |     ← (region, product_line, channel)
    APAC    | Hardware     | Retail  |   11,682.00   |
    APAC    | Hardware     | NULL    |   49,501.00   |     ← (region, product_line) subtotal
    APAC    | NULL         | Online  |   43,799.00   |     ← (region, channel) subtotal
    APAC    | NULL         | Retail  |   13,950.00   |
    APAC    | NULL         | NULL    |   57,749.00   |     ← (region) subtotal
    ...
    NULL    | Hardware     | NULL    |  149,702.00   |     ← (product_line) subtotal
    NULL    | Software     | NULL    |   41,483.00   |
    NULL    | NULL         | Online  |  148,575.00   |     ← (channel) subtotal
    NULL    | NULL         | Retail  |   42,610.00   |
    NULL    | NULL         | NULL    |  191,185.00   |     ← grand total
    

    Warning: Be careful with CUBE on high-cardinality columns. A CUBE over 6 columns produces 64 grouping combinations — and if each grouping combination itself has many distinct values, your result set explodes. CUBE is most appropriate when you have a small number of low-cardinality dimensions.

    When to Use CUBE vs ROLLUP

    Use ROLLUP when your dimensions are inherently hierarchical and you only need to roll up in one direction (geographic hierarchy, date hierarchy, organizational hierarchy).

    Use CUBE when your report consumers need to slice data along any axis — when a product manager might filter by region, a finance person might filter by channel, and a category manager might filter by product line, and all of them need subtotals within their chosen dimension.


    GROUPING SETS: Surgical Precision Over Aggregation Levels

    GROUPING SETS is the most flexible of the three. Instead of following a formula (like ROLLUP's n+1 levels or CUBE's 2^n levels), you explicitly list exactly which groupings you want. It's the SQL equivalent of specifying exactly which UNION ALL branches you need — but executed as a single scan.

    The syntax looks like this:

    GROUP BY GROUPING SETS (
        (dim1, dim2),  -- grouping combination 1
        (dim1),        -- grouping combination 2
        ()             -- grand total (empty parens)
    )
    

    Replacing UNION ALL with GROUPING SETS

    Here's a classic case: you need (a) totals by region and product line, (b) totals by region alone, and (c) a grand total. The UNION ALL version:

    -- The verbose, fragile way
    SELECT region, product_line, SUM(units_sold * unit_price) AS revenue
    FROM sales
    GROUP BY region, product_line
    
    UNION ALL
    
    SELECT region, NULL, SUM(units_sold * unit_price)
    FROM sales
    GROUP BY region
    
    UNION ALL
    
    SELECT NULL, NULL, SUM(units_sold * unit_price)
    FROM sales;
    

    This scans the table three times. Now with GROUPING SETS:

    -- The elegant, single-scan way
    SELECT
        region,
        product_line,
        SUM(units_sold * unit_price) AS total_revenue
    FROM sales
    GROUP BY GROUPING SETS (
        (region, product_line),
        (region),
        ()
    )
    ORDER BY region NULLS LAST, product_line NULLS LAST;
    

    Same result, single table scan, and adding or removing a grouping level is a one-line change.

    A Real-World Use Case: Non-Hierarchical Multi-Level Report

    Imagine your VP needs a report that shows:

    1. Revenue by region and product line (for the main table)
    2. Revenue by channel (for a sidebar widget)
    3. Revenue by product line only (for a comparison chart)
    4. The grand total (for the KPI card at the top)

    Notice that levels 1, 2, and 3 are not in a hierarchy with each other. ROLLUP and CUBE can't express this cleanly. GROUPING SETS can:

    SELECT
        region,
        product_line,
        channel,
        SUM(units_sold * unit_price)               AS total_revenue,
        SUM(units_sold * (unit_price - unit_cost)) AS total_margin,
        COUNT(DISTINCT sale_id)                    AS transactions
    FROM sales
    GROUP BY GROUPING SETS (
        (region, product_line),     -- main breakdown table
        (channel),                  -- sidebar widget
        (product_line),             -- comparison chart
        ()                          -- KPI grand total
    )
    ORDER BY 
        GROUPING(region, product_line, channel),
        region   NULLS LAST,
        product_line NULLS LAST,
        channel  NULLS LAST;
    

    This single query produces all four output shapes that would normally require four separate queries or a complex UNION ALL chain.


    The GROUPING() Function: Distinguishing Real NULLs from Aggregation NULLs

    Here's the nasty problem hiding in all three operators: they use NULL to mean "this column was collapsed for aggregation," but your data might also legitimately contain NULLs. If country can be NULL in your sales table (say, for online-only transactions that weren't geographically tagged), how do you tell a real NULL from a rollup NULL?

    The GROUPING() function solves this. It returns 1 if the column was collapsed (aggregation NULL) and 0 if the column is part of the actual grouping (real value or real NULL).

    SELECT
        GROUPING(region)       AS is_region_rolled_up,
        GROUPING(product_line) AS is_product_rolled_up,
        region,
        product_line,
        SUM(units_sold * unit_price) AS total_revenue
    FROM sales
    GROUP BY ROLLUP(region, product_line)
    ORDER BY region NULLS LAST, product_line NULLS LAST;
    

    Output:

    is_region_rolled_up | is_product_rolled_up | region  | product_line | total_revenue
    --------------------|----------------------|---------|--------------|---------------
    0                   | 0                    | APAC    | Hardware     | 51,769.00
    0                   | 0                    | APAC    | Software     |  5,980.00
    0                   | 1                    | APAC    | NULL         | 57,749.00   ← APAC subtotal
    ...
    1                   | 1                    | NULL    | NULL         | 191,185.00  ← grand total
    

    Using GROUPING() to Label Rows Clearly

    The GROUPING() function shines when you use it inside a CASE expression to create human-readable row labels:

    SELECT
        CASE
            WHEN GROUPING(region) = 1       THEN '** GRAND TOTAL **'
            WHEN GROUPING(product_line) = 1 THEN region || ' — Subtotal'
            ELSE region
        END AS region_label,
        CASE
            WHEN GROUPING(product_line) = 1 THEN '(All Products)'
            ELSE product_line
        END AS product_label,
        SUM(units_sold * unit_price)               AS total_revenue,
        SUM(units_sold * (unit_price - unit_cost)) AS total_margin
    FROM sales
    GROUP BY ROLLUP(region, product_line)
    ORDER BY region NULLS LAST, product_line NULLS LAST;
    

    Now your report output is immediately readable without consumers needing to know that NULL means "subtotal":

    region_label             | product_label  | total_revenue | total_margin
    -------------------------|----------------|---------------|-------------
    APAC                     | Hardware       |    51,769.00  |   22,869.00
    APAC                     | Software       |     5,980.00  |    5,030.00
    APAC — Subtotal          | (All Products) |    57,749.00  |   27,899.00
    Europe                   | Hardware       |    51,006.00  |   21,146.00
    Europe                   | Software       |     9,956.00  |    8,516.00
    Europe — Subtotal        | (All Products) |    60,962.00  |   29,662.00
    North America            | Hardware       |    46,927.00  |   23,497.00
    North America            | Software       |    25,547.00  |   22,127.00
    North America — Subtotal | (All Products) |    72,474.00  |   45,624.00
    ** GRAND TOTAL **        | (All Products) |   191,185.00  |  103,185.00
    

    GROUPING_ID(): Numeric Grouping Level Identifier

    GROUPING_ID() takes multiple columns and returns a single integer that represents the bitmask of which columns are rolled up. This is useful for programmatic filtering and ordering.

    SELECT
        GROUPING_ID(region, product_line, channel) AS grouping_level,
        region,
        product_line,
        channel,
        SUM(units_sold * unit_price) AS total_revenue
    FROM sales
    GROUP BY CUBE(region, product_line, channel)
    ORDER BY grouping_level, region NULLS LAST, product_line NULLS LAST, channel NULLS LAST;
    

    GROUPING_ID returns:

    • 0 when all columns are present (full detail)
    • 1 when only channel is rolled up
    • 2 when only product_line is rolled up
    • 3 when both product_line and channel are rolled up
    • 4 when only region is rolled up
    • ...and so on up to 7 (grand total, all rolled up)

    Tip: Use GROUPING_ID() as an ORDER BY key to ensure detail rows always appear before subtotals before grand totals, regardless of the data values in those rows.


    Combining and Nesting Grouping Operators

    You can mix and nest these operators in the same GROUP BY clause to express complex reporting requirements precisely.

    Concatenating GROUPING SETS

    Multiple GROUPING SETS lists in the same GROUP BY clause are equivalent to their cross product:

    -- This:
    GROUP BY GROUPING SETS (a, b), GROUPING SETS (c, d)
    
    -- Is equivalent to:
    GROUP BY GROUPING SETS (
        (a, c), (a, d),
        (b, c), (b, d)
    )
    

    This is more of a theoretical note than a common pattern, but it explains how the database internally interprets mixed GROUP BY clauses.

    Expressing ROLLUP and CUBE Using GROUPING SETS

    Understanding that ROLLUP and CUBE are syntactic sugar over GROUPING SETS is genuinely useful for debugging. Here are the equivalencies:

    -- ROLLUP(a, b, c) is equivalent to:
    GROUPING SETS (
        (a, b, c),
        (a, b),
        (a),
        ()
    )
    
    -- CUBE(a, b) is equivalent to:
    GROUPING SETS (
        (a, b),
        (a),
        (b),
        ()
    )
    

    When a query is producing unexpected results, translating your ROLLUP or CUBE into explicit GROUPING SETS and checking each set in isolation is an excellent debugging strategy.

    Partial CUBE and Mixed Operators

    You can combine regular GROUP BY columns with CUBE or ROLLUP:

    -- Fix year, cube over region and product_line
    SELECT
        EXTRACT(YEAR FROM sale_date)   AS sale_year,
        region,
        product_line,
        SUM(units_sold * unit_price)   AS total_revenue
    FROM sales
    GROUP BY EXTRACT(YEAR FROM sale_date), CUBE(region, product_line)
    ORDER BY sale_year, region NULLS LAST, product_line NULLS LAST;
    

    This gives you a CUBE of region × product_line within each year, but never a cross-year total. Exactly what you want when year is a filter, not an aggregation target.


    Performance Considerations

    These operators aren't magic — they still require the database to compute multiple aggregation levels. Here's what to understand before deploying them in production.

    Single Pass vs. Multiple Passes

    The key performance advantage of ROLLUP, CUBE, and GROUPING SETS over UNION ALL is that most query engines can compute them in a single pass over the data (or at least reduce the number of passes dramatically compared to separate queries). The data is read once, sorted or hashed once, and then aggregations are rolled up from the most granular level.

    However, for CUBE with many dimensions, some engines will internally execute multiple group-by operations and merge them. Check your execution plan.

    -- In PostgreSQL, check how the engine handles it:
    EXPLAIN (ANALYZE, BUFFERS)
    SELECT region, product_line, SUM(units_sold * unit_price)
    FROM sales
    GROUP BY CUBE(region, product_line);
    

    Look for MixedAggregate or HashAggregate nodes. PostgreSQL's MixedAggregate node handles multiple grouping sets in a single pass and is generally very efficient.

    Indexes and ROLLUP

    For large tables, the scan cost dominates. A covering index on the grouping columns plus the measure columns can dramatically reduce IO:

    -- An index that supports region/product_line rollups
    CREATE INDEX idx_sales_region_product 
    ON sales(region, product_line) 
    INCLUDE (units_sold, unit_price, unit_cost);
    

    When to Pre-Aggregate

    For very large datasets (hundreds of millions of rows), computing a CUBE at query time may be prohibitive. In that case, consider materializing the aggregation results into a summary table using a scheduled job, and applying GROUPING SETS over the pre-aggregated data rather than raw transactions.


    Hands-On Exercise: Build a Complete Executive Sales Report

    Let's put everything together. You'll build a complete multi-level sales summary that a real dashboard could consume. The report needs to support:

    1. Revenue and margin detail by region, product line, and channel
    2. Subtotals by region and product line (collapsing channel)
    3. Subtotals by region alone (collapsing product line and channel)
    4. A grand total

    That's a ROLLUP(region, product_line, channel) — but we also want to add a cross-dimensional view: totals by product line regardless of region (which ROLLUP doesn't provide). So we'll use GROUPING SETS that mirrors a ROLLUP but adds an extra set.

    Part 1: Build the core aggregation

    WITH sales_summary AS (
        SELECT
            region,
            product_line,
            channel,
            GROUPING(region)                           AS g_region,
            GROUPING(product_line)                     AS g_product,
            GROUPING(channel)                          AS g_channel,
            GROUPING_ID(region, product_line, channel) AS grouping_level,
            SUM(units_sold * unit_price)               AS total_revenue,
            SUM(units_sold * (unit_price - unit_cost)) AS total_margin,
            SUM(units_sold)                            AS total_units,
            COUNT(DISTINCT sale_id)                    AS transactions
        FROM sales
        GROUP BY GROUPING SETS (
            (region, product_line, channel),  -- detail level
            (region, product_line),           -- region + product subtotals
            (region),                         -- region subtotals
            (product_line),                   -- product-only cross-regional view
            ()                                -- grand total
        )
    )
    SELECT
        grouping_level,
        CASE
            WHEN g_region = 1 AND g_product = 1 AND g_channel = 1
                THEN '★ GRAND TOTAL'
            WHEN g_region = 0 AND g_product = 1 AND g_channel = 1
                THEN '▶ ' || region || ' [Region Total]'
            WHEN g_region = 1 AND g_product = 0 AND g_channel = 1
                THEN '◆ ' || product_line || ' [All Regions]'
            WHEN g_region = 0 AND g_product = 0 AND g_channel = 1
                THEN '  ' || region || ' / ' || product_line || ' [Subtotal]'
            ELSE '    ' || region || ' / ' || product_line || ' / ' || channel
        END AS report_label,
        total_revenue,
        total_margin,
        ROUND(total_margin::NUMERIC / NULLIF(total_revenue, 0) * 100, 1) AS margin_pct,
        total_units,
        transactions
    FROM sales_summary
    ORDER BY
        grouping_level DESC,  -- grand total first, then less rolled-up levels
        region   NULLS LAST,
        product_line NULLS LAST,
        channel  NULLS LAST;
    

    Part 2: Add a running share of grand total

    Extend the CTE to include each row's percentage of the grand total revenue:

    WITH sales_summary AS (
        -- (same as above)
        SELECT
            region, product_line, channel,
            GROUPING(region)                           AS g_region,
            GROUPING(product_line)                     AS g_product,
            GROUPING(channel)                          AS g_channel,
            GROUPING_ID(region, product_line, channel) AS grouping_level,
            SUM(units_sold * unit_price)               AS total_revenue,
            SUM(units_sold * (unit_price - unit_cost)) AS total_margin,
            SUM(units_sold)                            AS total_units,
            COUNT(DISTINCT sale_id)                    AS transactions
        FROM sales
        GROUP BY GROUPING SETS (
            (region, product_line, channel),
            (region, product_line),
            (region),
            (product_line),
            ()
        )
    ),
    grand_total AS (
        SELECT total_revenue AS gt_revenue
        FROM sales_summary
        WHERE grouping_level = 7  -- all dimensions rolled up
    )
    SELECT
        s.grouping_level,
        CASE
            WHEN s.g_region = 1 AND s.g_product = 1 AND s.g_channel = 1
                THEN '★ GRAND TOTAL'
            WHEN s.g_region = 0 AND s.g_product = 1 AND s.g_channel = 1
                THEN '▶ ' || s.region || ' [Region Total]'
            WHEN s.g_region = 1 AND s.g_product = 0 AND s.g_channel = 1
                THEN '◆ ' || s.product_line || ' [All Regions]'
            WHEN s.g_region = 0 AND s.g_product = 0 AND s.g_channel = 1
                THEN '  ' || s.region || ' / ' || s.product_line || ' [Subtotal]'
            ELSE '    ' || s.region || ' / ' || s.product_line || ' / ' || s.channel
        END AS report_label,
        s.total_revenue,
        ROUND(s.total_revenue / g.gt_revenue * 100, 1) AS pct_of_total,
        s.total_margin,
        ROUND(s.total_margin::NUMERIC / NULLIF(s.total_revenue, 0) * 100, 1) AS margin_pct,
        s.total_units,
        s.transactions
    FROM sales_summary s
    CROSS JOIN grand_total g
    ORDER BY
        s.grouping_level DESC,
        s.region         NULLS LAST,
        s.product_line   NULLS LAST,
        s.channel        NULLS LAST;
    

    This gives you a fully labeled, hierarchical report with percentage-of-total and margin columns — exactly what you'd feed into a BI tool or export to a stakeholder.


    Common Mistakes & Troubleshooting

    Mistake 1: Treating All NULLs as Missing Data

    This is the most common error. When you see a NULL in a ROLLUP or CUBE result, your first instinct might be to filter it out with WHERE region IS NOT NULL. That drops all your subtotals. Always use GROUPING() to distinguish rollup NULLs from data NULLs before applying filters.

    -- WRONG: This removes all subtotal rows
    WHERE region IS NOT NULL
    
    -- RIGHT: This removes only true missing data, keeps subtotals  
    WHERE GROUPING(region) = 1 OR region IS NOT NULL
    

    Mistake 2: Putting Non-Aggregated Columns in SELECT Without Grouping Them

    If you include a column in SELECT that's not in any of your GROUPING SETS and not in an aggregate function, some databases will throw an error, others will return arbitrary values. Be explicit:

    -- WRONG: sale_date is not in any grouping set or aggregate
    SELECT region, sale_date, SUM(units_sold * unit_price)
    FROM sales
    GROUP BY ROLLUP(region)
    
    -- RIGHT: Either group it, aggregate it, or remove it
    SELECT region, MAX(sale_date), SUM(units_sold * unit_price)
    FROM sales
    GROUP BY ROLLUP(region)
    

    Mistake 3: Ordering Without Accounting for NULLs

    The default ORDER BY behavior for NULLs varies by database. In PostgreSQL, NULLs sort last in ascending order by default (NULLS LAST). In SQL Server, NULLs sort first. If your report needs grand totals at the bottom and detail at the top (or vice versa), use NULLS LAST / NULLS FIRST explicitly, or order by GROUPING_ID().

    -- Reliable cross-database ordering: detail first, grand total last
    ORDER BY 
        GROUPING_ID(region, product_line) ASC,  -- 0 = detail, higher = more rolled up
        region   NULLS LAST,
        product_line NULLS LAST;
    

    Mistake 4: Using CUBE When ROLLUP or GROUPING SETS Would Do

    A 4-column CUBE produces 16 grouping combinations. Many of those may be meaningless for your report. If you only need 5 of the 16 combinations, use GROUPING SETS with those 5 explicitly listed. You'll get a smaller result set and likely better performance.

    Mistake 5: Forgetting Parentheses in GROUPING SETS

    GROUPING SETS requires each set to be wrapped in parentheses, even if it's a single column. A common syntax error:

    -- WRONG: Missing inner parentheses
    GROUP BY GROUPING SETS (region, product_line, ())
    
    -- RIGHT: Each set needs its own parens
    GROUP BY GROUPING SETS ((region), (product_line), ())
    

    The first version would be interpreted as GROUPING SETS over the columns region, product_line, and an empty set — which is actually the correct behavior in this case, but only coincidentally. For multi-column sets, the distinction is critical:

    -- These are very different things:
    GROUP BY GROUPING SETS (region, product_line)     -- two single-column sets
    GROUP BY GROUPING SETS ((region, product_line))   -- one two-column set
    

    Mistake 6: Conflating ROLLUP Column Order with GROUPING SETS Behavior

    Remember that ROLLUP(a, b, c) always collapses from right to left. If you want a specific non-hierarchical combination, you cannot get it with ROLLUP alone. For example, ROLLUP(region, product_line) will give you (region, product_line), (region), and () — but NOT (product_line) alone. If you need (product_line) as a separate grouping, add it explicitly with GROUPING SETS or switch entirely to GROUPING SETS.


    Database Compatibility Notes

    Before you copy this into production, a quick sanity check on dialect differences:

    Feature PostgreSQL SQL Server MySQL 8+ BigQuery Snowflake
    ROLLUP ✅ ✅ ✅ ✅ ✅
    CUBE ✅ ✅ ✅ ✅ ✅
    GROUPING SETS ✅ ✅ ✅ ✅ ✅
    GROUPING() ✅ ✅ ✅ ✅ ✅
    GROUPING_ID() ✅ ✅ ✅ ✅ ✅
    NULLS LAST in ORDER BY ✅ ❌ (use ISNULL trick) ✅ ✅ ✅

    In SQL Server, to sort NULLs last:

    ORDER BY ISNULL(region, 'ZZZZ'), ISNULL(product_line, 'ZZZZ')
    

    Summary & Next Steps

    You now have a complete picture of how multi-level aggregation works in SQL:

    • ROLLUP follows a hierarchy, collapsing dimensions from right to left, producing n+1 grouping levels. Use it for geographic, temporal, or organizational hierarchies.
    • CUBE produces every possible subset of your dimensions. Use it for cross-dimensional analysis where any dimension might be the primary filter. Beware of combinatorial explosion with many columns.
    • GROUPING SETS is the foundation both operators build on. Use it when you need precise control over exactly which aggregation levels appear — which is most of the time in production reporting.
    • GROUPING() and GROUPING_ID() are essential companions. Always use them to distinguish rollup NULLs from data NULLs, to produce readable row labels, and to control sort order.

    What to Practice Next

    The best way to solidify this is to grab a real dataset you work with — customer transactions, web events, operational data — and build a multi-level summary report that you'd actually use. Start with ROLLUP on a two-level hierarchy, confirm the subtotals match what you'd get from separate queries, then add GROUPING() labels.

    Natural next topics to explore:

    • Window functions alongside grouping sets — using SUM() OVER () to compute percent-of-total inside a ROLLUP query without a separate CTE
    • Materialized views for pre-aggregation — storing CUBE results to serve dashboard queries at millisecond speed
    • Pivoting GROUPING SETS output — using FILTER clauses or conditional aggregation to turn row-level groupings into column-level comparisons
    • Data modeling for reporting — how star schemas and aggregate tables interact with these operators in a data warehouse context

    The operators you learned today are workhorses of analytics SQL. Once you're comfortable with them, you'll find yourself reaching for GROUPING SETS any time you see a UNION ALL aggregation chain — and cleaning up a lot of fragile SQL in the process.

    Learning Path: Advanced SQL Queries

    Previous

    Understanding SQL NULL Handling: COALESCE, NULLIF, and IS NULL for Reliable Data Queries

    Related Articles

    SQL🌱 Foundation

    Understanding SQL NULL Handling: COALESCE, NULLIF, and IS NULL for Reliable Data Queries

    15 min
    SQL🔥 Expert

    Graph Traversal in SQL: Recursive CTEs and Adjacency Lists for Network and Relationship Analysis

    31 min
    SQL⚡ Practitioner

    Fuzzy Matching and Deduplication in SQL: Using Similarity Functions and Blocking Strategies to Find Near-Duplicate Records

    22 min

    On this page

    • Introduction
    • Prerequisites
    • Setting Up the Dataset
    • ROLLUP: Hierarchical Subtotals Along a Chain
    • Your First ROLLUP
    • Three-Level ROLLUP: Region > Country > Product Line
    • Partial ROLLUP: Mixing Fixed and Rolled-Up Dimensions
    • CUBE: Every Possible Combination of Subtotals
    • A Three-Dimensional CUBE
    • When to Use CUBE vs ROLLUP
    • GROUPING SETS: Surgical Precision Over Aggregation Levels
    • Replacing UNION ALL with GROUPING SETS
    • A Real-World Use Case: Non-Hierarchical Multi-Level Report
    • The GROUPING() Function: Distinguishing Real NULLs from Aggregation NULLs
    • Using GROUPING() to Label Rows Clearly
    • GROUPING_ID(): Numeric Grouping Level Identifier
    • Combining and Nesting Grouping Operators
    • Concatenating GROUPING SETS
    • Expressing ROLLUP and CUBE Using GROUPING SETS
    • Partial CUBE and Mixed Operators
    • Performance Considerations
    • Single Pass vs. Multiple Passes
    • Indexes and ROLLUP
    • When to Pre-Aggregate
    • Hands-On Exercise: Build a Complete Executive Sales Report
    • Common Mistakes & Troubleshooting
    • Mistake 1: Treating All NULLs as Missing Data
    • Mistake 2: Putting Non-Aggregated Columns in SELECT Without Grouping Them
    • Mistake 3: Ordering Without Accounting for NULLs
    • Mistake 4: Using CUBE When ROLLUP or GROUPING SETS Would Do
    • Mistake 5: Forgetting Parentheses in GROUPING SETS
    • Mistake 6: Conflating ROLLUP Column Order with GROUPING SETS Behavior
    • Database Compatibility Notes
    • Summary & Next Steps
    • What to Practice Next