Chapter 06 · Advanced SQL: Aggregates, Windows, GROUPING SETS, and MERGE

GROUPING SETS, CUBE, ROLLUP, and Multi-Level Analytical Reports

Produce multiple PostgreSQL aggregation levels in one query with GROUPING SETS, ROLLUP, and CUBE, distinguish subtotal NULLs from real NULL data using GROUPING(), and compare maintainability and plan evidence with repeated UNION ALL reports.

Intermediate125–155 minutesMulti-level aggregation + subtotal semantics labCurrent patched PostgreSQL 18.xCore SQL; optional custom aggregate uses only local SQLLast reviewed: August 2026

Learning outcomes

ServiceHub's monthly report needs totals by region and team, totals by region, totals by team, and one grand total. Repeating four nearly-identical queries with UNION ALL works, but it duplicates filters and expressions and makes it easy for one branch to drift. PostgreSQL grouping sets let one grouping specification describe several aggregation levels.

01

Express several aggregation levels with GROUPING SETS and understand its UNION-ALL-like relational meaning.

02

Use ROLLUP for hierarchical prefixes and CUBE for all subsets of selected grouping dimensions.

03

Use GROUPING() to distinguish subtotal placeholders from real NULL values in source data.

04

Create stable report labels without incorrectly applying COALESCE to subtotal and data NULLs alike.

05

Compare grouping-set and repeated-UNION designs using readability, exact results, and plan evidence rather than universal speed claims.

1. Chapter 06 lab bootstrap

All five lessons use one small, deterministic ServiceHub dataset. Run this in the disposable servicehub_lab database as servicehub_owner or another role that owns the app schema. The objects are intentionally prefixed ch06_ so Chapter 06 can be reset without touching earlier chapters.

Safety boundary

The reset is intentionally explicit and does not use CASCADE. Verify the database and matching objects before executing it. If any ch06_* object contains valuable data, stop and use another disposable lab database.

sql · reset and seed Chapter 06 ServiceHub data
\echo 'Verify target before recreating Chapter 06 objects'SELECT current_database(), current_user;\dt app.ch06_*DROP VIEW IF EXISTS app.ch06_region_team_report;DROP AGGREGATE IF EXISTS app.ch06_sum_squares(numeric);DROP FUNCTION IF EXISTS app.ch06_add_square(numeric, numeric);DROP TABLE IF EXISTS app.ch06_inventory_stage;DROP TABLE IF EXISTS app.ch06_inventory;DROP TABLE IF EXISTS app.ch06_daily_metric;DROP TABLE IF EXISTS app.ch06_work_order;CREATE TABLE app.ch06_work_order (    work_order_id integer PRIMARY KEY,    customer_id integer NOT NULL,    region text,    team_code text NOT NULL,    status text NOT NULL CHECK (status IN ('open','closed','cancelled')),    priority smallint NOT NULL CHECK (priority BETWEEN 1 AND 4),    opened_on date NOT NULL,    closed_on date,    actual_minutes integer CHECK (actual_minutes >= 0),    cost numeric(10,2) NOT NULL CHECK (cost >= 0));INSERT INTO app.ch06_work_order VALUES(1, 1,'North',  'A','closed',   1,'2026-08-01','2026-08-01', 40,200.00),(2, 2,'North',  'A','closed',   2,'2026-08-01','2026-08-02', 90,150.00),(3, 3,'North',  'B','closed',   1,'2026-08-02','2026-08-02', 60,300.00),(4, 4,'South',  'B','open',     2,'2026-08-02',NULL,          NULL,120.00),(5, 4,'South',  'B','closed',   3,'2026-08-03','2026-08-04',180,500.00),(6, 5,'South',  'C','closed',   1,'2026-08-03','2026-08-03', 30, 80.00),(7, 1,'Central','A','closed',   2,'2026-08-04','2026-08-05',120,250.00),(8, 6,'Central','C','open',     1,'2026-08-04',NULL,          NULL,110.00),(9, 7,NULL,      'C','closed',  2,'2026-08-05','2026-08-05', 75,130.00),(10,2,'North',   'A','closed',  3,'2026-08-05','2026-08-06',210,600.00),(11,8,'South',   'C','cancelled',4,'2026-08-06','2026-08-06',10,  0.00),(12,3,'Central', 'B','closed',  1,'2026-08-06','2026-08-07', 50,175.00);CREATE TABLE app.ch06_daily_metric (    metric_id integer PRIMARY KEY,    metric_date date NOT NULL,    region text,    completed_count integer NOT NULL CHECK (completed_count >= 0),    revenue numeric(10,2) NOT NULL CHECK (revenue >= 0),    UNIQUE NULLS NOT DISTINCT (metric_date, region));INSERT INTO app.ch06_daily_metric VALUES(1,'2026-08-01','North',   1,200.00),(2,'2026-08-02','North',   2,450.00),(3,'2026-08-03','South',   1, 80.00),(4,'2026-08-04','South',   1,500.00),(5,'2026-08-05','Central', 1,250.00),(6,'2026-08-05',NULL,      1,130.00),(7,'2026-08-06','North',   1,600.00),(8,'2026-08-07','Central', 1,175.00);CREATE TABLE app.ch06_inventory (    sku text PRIMARY KEY,    description text NOT NULL,    on_hand integer NOT NULL CHECK (on_hand >= 0),    reorder_point integer NOT NULL CHECK (reorder_point >= 0),    active boolean NOT NULL DEFAULT true,    updated_at timestamptz NOT NULL DEFAULT clock_timestamp());INSERT INTO app.ch06_inventory (sku, description, on_hand, reorder_point) VALUES('A-100','Fuse kit',5,5),('B-200','Patch cable',20,10),('C-300','Pump seal',7,3);CREATE TABLE app.ch06_inventory_stage (    sku text PRIMARY KEY,    description text NOT NULL,    on_hand integer NOT NULL CHECK (on_hand >= 0),    reorder_point integer NOT NULL CHECK (reorder_point >= 0));INSERT INTO app.ch06_inventory_stage VALUES('A-100','Fuse kit',8,5),('B-200','Patch cable',0,10),('D-400','Sensor battery',12,4);

The seed contains nine closed work orders, two open work orders, one cancelled work order, a real NULL region, repeated dates, and a small inventory snapshot. Those details create observable edge cases for aggregate NULL handling, peer-aware window frames, subtotal NULLs, and DML synchronization.

2. GROUPING SETS is one specification of multiple groups

Each grouping set is processed as if it were an independent GROUP BY level and the results were combined. The empty grouping set () means the grand total. PostgreSQL can implement the work in a plan that shares scans or aggregation machinery, but the semantic result—not a guaranteed physical strategy—is the reason to choose the construct.

sql · region/team, region, team, and grand total
SELECT region, team_code,       count(*) FILTER (WHERE status='closed') AS closed_orders,       sum(cost) FILTER (WHERE status='closed') AS closed_cost,       GROUPING(region) AS g_region,       GROUPING(team_code) AS g_teamFROM app.ch06_work_orderGROUP BY GROUPING SETS (  (region, team_code),  (region),  (team_code),  ())ORDER BY g_region, region NULLS LAST,         g_team, team_code NULLS LAST;

A value of GROUPING(region)=1 means region is absent from that grouping set and the output NULL is a subtotal placeholder. A zero means region participates in the grouping set—even if the actual data value is NULL.

3. The real-NULL trap: COALESCE alone cannot label subtotals

The seed deliberately contains work order 9 with a real NULL region. If you write COALESCE(region,'ALL REGIONS'), that genuine missing/unknown region is indistinguishable from a subtotal row in which region was not grouped. Use GROUPING first; only then decide how to display a real NULL.

sql · correct subtotal labeling
SELECT  CASE    WHEN GROUPING(region)=1 THEN 'ALL REGIONS'    WHEN region IS NULL THEN '(unknown region)'    ELSE region  END AS region_label,  CASE    WHEN GROUPING(team_code)=1 THEN 'ALL TEAMS'    ELSE team_code  END AS team_label,  count(*) FILTER (WHERE status='closed') AS closed_orders,  COALESCE(sum(cost) FILTER (WHERE status='closed'),0) AS closed_cost,  GROUPING(region) AS g_region,  GROUPING(team_code) AS g_teamFROM app.ch06_work_orderGROUP BY GROUPING SETS ((region,team_code),(region),())ORDER BY g_region, region NULLS LAST, g_team, team_code NULLS LAST;
Failure diagnosis

If an “unknown region” detail row and the grand-total row both appear as ALL REGIONS, the bug is not missing data—it is a report-labeling error. GROUPING() records whether the column is present in the grouping set and is the correct discriminator.

4. ROLLUP expresses a hierarchy of prefixes

ROLLUP(region, team_code) represents (region,team_code), (region), and (). It is useful when the grouping dimensions form a reporting hierarchy. It does not automatically know organizational parent-child relationships; the hierarchy is the order you wrote.

sql · detail, regional subtotal, grand total
SELECT region, team_code,       count(*) AS orders,       sum(cost) AS cost,       GROUPING(region) AS g_region,       GROUPING(team_code) AS g_teamFROM app.ch06_work_orderWHERE status <> 'cancelled'GROUP BY ROLLUP (region, team_code)ORDER BY GROUPING(region), region NULLS LAST,         GROUPING(team_code), team_code NULLS LAST;

ROLLUP(team_code, region) is a different report: it produces team subtotals instead of regional subtotals. Ordering of dimensions is therefore semantic.

5. CUBE produces every subset

CUBE(region, team_code) represents four grouping sets: (region,team_code), (region), (team_code), and (). With more dimensions, the number of grouping sets grows as a power set. A cube is valuable when those cross-dimensional subtotals are actually required; it is wasteful when the consumer only needs a simple hierarchy.

sql · cross-dimensional subtotal cube
SELECT region, team_code,       sum(cost) FILTER (WHERE status='closed') AS closed_cost,       GROUPING(region, team_code) AS grouping_maskFROM app.ch06_work_orderGROUP BY CUBE (region, team_code)ORDER BY grouping_mask, region NULLS LAST, team_code NULLS LAST;

GROUPING(region, team_code) packs the individual grouping flags into a bit mask. For report code that prioritizes clarity, separate GROUPING(region) and GROUPING(team_code) columns can be easier to maintain.

6. Compare with repeated UNION ALL without promising a winner

sql · semantically equivalent two-level example
-- Grouping-set formSELECT region, count(*) AS ordersFROM app.ch06_work_orderGROUP BY GROUPING SETS ((region), ());-- Repeated-query formSELECT region, count(*) AS ordersFROM app.ch06_work_orderGROUP BY regionUNION ALLSELECT NULL AS region, count(*) AS ordersFROM app.ch06_work_order;

The second form repeats the base relation and any filters/derived expressions. The first centralizes them. PostgreSQL may optimize the grouping-set form with shared work, but actual plans depend on data, statistics, expressions, version, and configuration.

sql · inspect work instead of guessing
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)SELECT region, team_code, count(*)FROM app.ch06_work_orderGROUP BY GROUPING SETS ((region,team_code),(region),(team_code),());

Do not copy a sample plan node as a contract. Record scan counts, actual rows, aggregate strategy, sort/spill evidence, buffer usage, and elapsed summary from your own lab.

7. Hands-on lab: a production-shaped subtotal report

sql · closed-order operations report with stable level names
SELECT  CASE    WHEN GROUPING(region)=1 AND GROUPING(team_code)=1 THEN 'grand_total'    WHEN GROUPING(team_code)=1 THEN 'region_total'    ELSE 'region_team'  END AS level,  CASE    WHEN GROUPING(region)=1 THEN NULL    ELSE region  END AS region,  CASE    WHEN GROUPING(team_code)=1 THEN NULL    ELSE team_code  END AS team_code,  count(*) FILTER (WHERE status='closed') AS closed_orders,  sum(cost) FILTER (WHERE status='closed') AS closed_costFROM app.ch06_work_orderGROUP BY ROLLUP(region, team_code)ORDER BY GROUPING(region), region NULLS LAST,         GROUPING(team_code), team_code NULLS LAST;

Machine-facing reports often benefit from explicit level plus raw nullable dimensions instead of embedding presentation labels into the same columns. That makes subtotal semantics easier for APIs and BI consumers to validate.

Check your understanding

  1. What does the empty grouping set () represent?
  2. Why can COALESCE(region,'ALL') corrupt a report that contains real NULL regions?
  3. How does ROLLUP(a,b) differ from CUBE(a,b)?
  4. What does GROUPING(column)=1 mean?
  5. Why should a grouping-set query not be described as always faster than UNION ALL?
Review the answers

The empty set is the grand total. COALESCE cannot distinguish a real NULL data value from a subtotal placeholder. ROLLUP(a,b) emits (a,b),(a),(), while CUBE(a,b) also includes (b). GROUPING=1 means the expression is absent from that grouping set. Planner strategy and workload determine performance, so measure actual plans rather than universalizing one result.

8. Bridge to observable data modification

Multi-level reads combine several relational outcomes in one statement. Lesson 4 applies the same discipline to writes: every proposed row must have a defined conflict/match classification, action, observable old/new state, and concurrency contract.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.