GROUP BY — Learn Pivoting, Time-Series Aggregation, ROLLUP, Share Analysis, and List Aggregation in Practice
AdvancedFILTER pivotingDATE_TRUNC time seriesROLLUP / GROUPINGShare / ABC analysisSTRING_AGGPostgreSQL-ready5 questions
QUESTION 1
Pivot with conditional aggregation — Turn rows into columns with FILTER
FILTERCOALESCEPivotingRows → columns
Background

Applying FILTER from the basic level lets you expand long-format data into a wide cross-tabulation (pivot). Place each status aggregate in its own column to create a summary table with one row per category.

-- Create a "column" for each status value (pivot)
SUM(amount) FILTER (WHERE status = 'completed') -- completed column
SUM(amount) FILTER (WHERE status = 'pending')   -- pending column
COALESCE(SUM(...) FILTER (...), 0)            -- turn NULL for no match into 0
When FILTER returns NULL: If a group has no matching rows, SUM(...) FILTER(...) returns NULL, not 0. In a cross-tabulation, you often want an empty cell to display as 0, so use COALESCE(aggregate, 0) to convert it. The CASE expression form, SUM(CASE WHEN status='completed' THEN amount ELSE 0 END), produces the same table.
Problem

From orders, create a pivot table with categories as rows and statuses as columns. Output columns: category, completed_amt, pending_amt, cancelled_amt, total_amt. Use 0 for a cell with no matching rows, and sort by total_amt descending.

Tables used
► orders (8 rows)
order_idcategorystatusamount
1Bookscompleted1200
2Bookscompleted800
3Bookscancelled500
4Toyscompleted3000
5Toyspending1500
6Toyscancelled1000
7Foodcompleted600
8Foodpending400

Note: Books has no pending order, so pending_amt is 0; Food has no cancelled order, so cancelled_amt is 0.

Expected Output

Expected output (total_amt descending):

categorycompleted_amtpending_amtcancelled_amttotal_amt
Toys3000150010005500
Books200005002500
Food60040001000

The three status columns are expanded while the rows remain the three category groups. total_amt is the sum across all statuses.

QUESTION 2
Group time series with DATE_TRUNC — Round dates to the month for monthly aggregation
DATE_TRUNCTime-series aggregationMonthly bucketsDate truncation
Background

When aggregating time-series data, truncate dates to units such as months or weeks before grouping. PostgreSQL's DATE_TRUNC('month', ts) rounds a timestamp to the first day of that month at 00:00, so rows from the same month naturally fall into one group.

-- Truncate to the month start and group by "month"
DATE_TRUNC('month', order_date)   -- 2024-02-15 → 2024-02-01
GROUP BY DATE_TRUNC('month', order_date)
Truncation creates "buckets": GROUP BY on a raw date splits rows into one group per day (strictly, per timestamp). DATE_TRUNC reduces the granularity to a month so rows from the same month enter the same "bucket". For display, dropping the time with ::date makes the result easier to read.
Problem

From orders, calculate the monthly order count and total amount. Output columns: month, order_count, total_amount. Represent month as the first day of the month (date type), and sort by month ascending.

Tables used
► orders (7 rows)
order_idorder_dateamount
12024-01-051000
22024-01-201500
32024-02-032000
42024-02-15500
52024-02-281000
62024-03-103000
72024-03-222000

Note: data covers January–March 2024: January = 2 rows, February = 3, March = 2.

Expected Output

Expected output (month ascending):

monthorder_counttotal_amount
2024-01-0122500
2024-02-0133500
2024-03-0125000

The 7 detail rows collapse into three month groups keyed by the first day of the month.

QUESTION 3
Subtotals and grand totals with ROLLUP — Return detail, category subtotals, and the overall total in one query
ROLLUPGROUPINGSubtotals / grand totalAggregation hierarchy
Background

For reports with total rows, GROUP BY ROLLUP is the shortest route. In addition to detail (a,b), ROLLUP(a, b) outputs the subtotal (a) and the grand total () together in one query.

-- Generate detail + category subtotals + grand total at once
GROUP BY ROLLUP (category, status)
-- Aggregate levels generated: (cat,status) / (cat) / ()
GROUPING(status)  -- 1 means a subtotal/grand-total row with status rolled up
Distinguish NULL in subtotal rows: In subtotal and grand-total rows, a rolled-up column becomes NULL. This is not a "no data" NULL; it means "this dimension has been aggregated." Use GROUPING(column) (1 for a subtotal or total at that column) to distinguish it from a real NULL and to label or sort the rows.
Problem

From orders, return the category × status details, category subtotals, and overall grand total in one query. Display subtotal rows with status "(Subtotal)"; display the grand-total row with category "[All Categories]" and status "(Grand Total)". Return the rows in report order (category → details → subtotal → grand total last).

Tables used
► orders (6 rows)
order_idcategorystatusamount
1Bookscompleted1200
2Bookscancelled800
3Toyscompleted3000
4Toyscompleted1000
5Toyscancelled500
6Foodcompleted600

Note: 5 detail rows + 3 category subtotal rows + 1 grand-total row = 9 rows. The grand total is 1200+800+3000+1000+500+600 = 7100.

Expected Output

Expected output (detail → subtotal → grand total report order):

categorystatustotal_amount
Bookscancelled800
Bookscompleted1200
Books(Subtotal)2000
Foodcompleted600
Food(Subtotal)600
Toyscancelled500
Toyscompleted4000
Toys(Subtotal)4500
[All Categories](Grand Total)7100

Within each category, details come before the subtotal; the grand total follows all categories.

QUESTION 4
Share and cumulative share — Use GROUP BY and window functions for each category's share of the whole
SUM() OVER ()ShareTwo-level aggregationABC analysis
Background

To show what percentage of the whole each category represents, layer a window function on top of the grouped result. In SUM(SUM(amount)) OVER (), the inner SUM is the group total and the outer SUM is the grand total across all groups: this is two-level aggregation.

-- share = group total / grand total
SUM(amount) * 100.0 / SUM(SUM(amount)) OVER ()
-- cumulative share (accumulated from largest total downward)
SUM(SUM(amount)) OVER (ORDER BY SUM(amount) DESC)
Window functions run after GROUP BY: The evaluation order is GROUP BY → aggregate → window function. That is why an aggregate value such as SUM(amount) can be referenced inside a window and why the nested SUM(SUM(...)) works. OVER () creates one window over all rows; OVER (ORDER BY ...) creates a cumulative (running) total.
Problem

From orders, calculate the category total, share of the whole (%), and cumulative share (%). Output columns: category, total_amount, pct_of_total, cumulative_pct. Sort by total descending (the ABC-analysis order), and round percentages to one decimal place.

Tables used
► orders (6 rows)
order_idcategoryamount
1Books1000
2Books1000
3Toys3000
4Toys2000
5Food2000
6Food1000

Note: Books=2000, Toys=5000, Food=3000, for a grand total of 10000. The shares are 20% / 50% / 30%.

Expected Output

Expected output (total_amount descending):

categorytotal_amountpct_of_totalcumulative_pct
Toys500050.050.0
Food300030.080.0
Books200020.0100.0

Cumulative from the top: 50.0 → 50.0+30.0=80.0 → +20.0=100.0.

QUESTION 5
STRING_AGG / ARRAY_AGG — Aggregate values within each group into one list
STRING_AGGARRAY_AGGHAVINGList aggregation
Background

Use STRING_AGG (string concatenation) or ARRAY_AGG (array construction) when you want to combine values within a group into one list. A defining feature is that ORDER BY and DISTINCT can be written inside the aggregate function.

-- Concatenate group values with commas (deduplicated and sorted)
STRING_AGG(DISTINCT product, ', ' ORDER BY product)
-- Aggregate into an array
ARRAY_AGG(DISTINCT product ORDER BY product)
ORDER BY inside an aggregate: The ORDER BY in STRING_AGG(expr, delimiter ORDER BY ...) determines the order of list elements; it is different from the ORDER BY at the end of the query. Adding DISTINCT combines duplicate elements into one (and the ORDER BY expression must match the DISTINCT expression).
Problem

From sales, calculate the number of distinct products, a product-name list, and the total quantity per category. Remove duplicate names and sort the product list alphabetically. Output columns: category, product_count, products, total_qty. Return only categories with at least 2 distinct products, sorted by total_qty descending.

Tables used
► sales (7 rows)
idcategoryproductqty
1BooksSQL Guide3
2BooksPython1
3BooksSQL Guide2
4ToysBlocks5
5ToysPuzzle2
6ToysBlocks1
7FoodCoffee4

Note: Books has two SQL Guide rows, but it is one product type. Food has only one product type, Coffee.

Expected Output

Expected output (total_qty descending):

categoryproduct_countproductstotal_qty
Toys2Blocks, Puzzle8
Books2Python, SQL Guide6

Food is excluded by HAVING because it has only one distinct product.