Week 6: Aggregation, Grouping & Summarizing Data in SQL

Week 2 introduced GROUP BY with one column. This week pushes it further: grouping by several columns at once, filtering which groups survive with more than one condition, and reshaping category data into columns with CASE WHEN — the SQL equivalent of the PivotTable field arrangement from Week 3. It closes with the habits that turn a working query into one a teammate can actually read.

Module 6 of 12 Week 6 of 12 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Group by multiple columns to summarize at exactly the level of detail a question needs
  • Turn categories into columns with CASE WHEN inside an aggregate
  • Write a query with consistent formatting and aliases that a teammate can follow at a glance

1. GROUP BY With Multiple Columns

This week extends Week 5's orders table with a category column, alongside the same customers table:

orders (table, extended)
order_id | customer_id | category    | amount | order_date
---------+-------------+-------------+--------+------------
1        | 1           | Electronics | 1200   | 2026-01-04
2        | 2           | Clothing    | 450    | 2026-01-05
3        | 1           | Electronics | 890    | 2026-01-06
4        | 1           | Clothing    | 300    | 2026-01-09
5        | 4           | Electronics | NULL   | 2026-01-10
6        | 3           | Clothing    | 700    | 2026-02-02
7        | 2           | Electronics | 1500   | 2026-02-03

A single GROUP BY region collapses everything down to one row per region. Listing more than one column groups by every unique combination of them instead — a finer-grained summary, without changing anything else about the query:

sql
SELECT c.region, o.category, SUM(o.amount) AS total_amount
FROM orders o
INNER JOIN customers c ON o.customer_id = c.customer_id
GROUP BY c.region, o.category;

-- One row per (region, category) PAIR that actually appears in the data --
-- e.g. North/Electronics and North/Clothing show up as two separate rows,
-- not blended into one "North" total

This is exactly the same idea as dragging a second field into a PivotTable's Rows zone in Week 3 — each additional GROUP BY column adds another level of detail to what counts as "the same group," and only combinations that actually exist in the data produce a row.

2. HAVING, Revisited

Week 2 covered HAVING with a single condition. It combines with AND/OR exactly like WHERE does, and — the detail worth re-emphasizing — it can only reference columns that are either grouped on or wrapped in an aggregate function:

sql
SELECT c.region, o.category, SUM(o.amount) AS total_amount, COUNT(*) AS order_count
FROM orders o
INNER JOIN customers c ON o.customer_id = c.customer_id
GROUP BY c.region, o.category
HAVING SUM(o.amount) > 1000 AND COUNT(*) > 1;

-- Only (region, category) groups with BOTH a total over 1000 AND more than
-- one order survive -- a single big order no longer qualifies on its own

Requiring both conditions together is a common real pattern: "significant total and repeat behavior" filters out a one-off large purchase that happens to clear the revenue bar but doesn't actually represent a meaningful trend.

3. CASE WHEN for Conditional Aggregation

CASE WHEN is SQL's inline if/else — and paired with an aggregate function, it's the standard way to turn category values into their own columns, without a dedicated PIVOT keyword:

sql
SELECT
  c.region,
  SUM(CASE WHEN o.category = 'Electronics' THEN o.amount ELSE 0 END) AS electronics_total,
  SUM(CASE WHEN o.category = 'Clothing'    THEN o.amount ELSE 0 END) AS clothing_total,
  SUM(o.amount) AS grand_total
FROM orders o
INNER JOIN customers c ON o.customer_id = c.customer_id
GROUP BY c.region;

-- One row per region, but Electronics and Clothing spend are now SEPARATE
-- COLUMNS instead of separate rows -- exactly the shape a chart or report usually wants

Read the pattern as: for each row, check the condition; if true, count o.amount toward this column's sum, otherwise count 0 (contributing nothing). Repeating that per category, each wrapped in its own SUM, produces one column per category — all computed in a single pass over the data, in a single query.

CASE WHEN also works outside aggregates

The exact same syntax, used without SUM(...) around it, works as a row-level label — CASE WHEN amount > 1000 THEN 'High Value' ELSE 'Standard' END AS tier is the direct SQL equivalent of Week 1's nested IF formula, just evaluated per row instead of per cell.

4. Combining Aggregates in One Query

Multiple aggregate functions, including COUNT(DISTINCT ...), can all sit side by side in the same SELECT — each one summarizes independently over whatever rows made it into the current group:

sql
SELECT
  c.region,
  COUNT(*) AS order_count,
  COUNT(DISTINCT o.customer_id) AS unique_customers,
  ROUND(AVG(o.amount), 2) AS avg_order_value,
  SUM(o.amount) AS total_revenue
FROM orders o
INNER JOIN customers c ON o.customer_id = c.customer_id
GROUP BY c.region;

COUNT(*) counts rows; COUNT(DISTINCT o.customer_id) counts unique customers — a very different number the moment any customer has placed more than one order, and the difference between the two is itself a useful metric ("orders per customer"). ROUND(..., 2) is worth reaching for on any AVG headed for a report — raw floating-point division rarely lands on a clean two decimal places on its own.

5. Writing Readable Queries

A query that returns the right answer but takes five minutes for a teammate to parse is still a cost — a few habits keep that cost low:

a few small habits, compounding
-- 1. One clause per line, consistently indented -- scan the shape, not the words
SELECT
  c.region,
  SUM(o.amount) AS total_revenue
FROM orders o
INNER JOIN customers c ON o.customer_id = c.customer_id
WHERE o.order_date >= '2026-01-01'
GROUP BY c.region
HAVING SUM(o.amount) > 500
ORDER BY total_revenue DESC;

-- 2. Always alias an aggregate -- "SUM(amount)" as a raw column header helps no one
SELECT SUM(amount) AS total_revenue FROM orders;   -- do this
SELECT SUM(amount) FROM orders;                     -- not this

-- 3. WITH (a Common Table Expression, or CTE) names an intermediate result --
-- a preview of Week 7's main topic, but useful for readability starting now
WITH regional_totals AS (
  SELECT c.region, SUM(o.amount) AS total_amount
  FROM orders o
  INNER JOIN customers c ON o.customer_id = c.customer_id
  GROUP BY c.region
)
SELECT * FROM regional_totals WHERE total_amount > 1000;

None of these change what a query returns — only how quickly someone else (including you, in six months) can understand it. A named CTE in particular turns "one long, nested query" into "a sequence of clearly labeled steps," which is exactly the readability upgrade Week 7 builds on directly.

6. Hands-on Exercise

Hands-on

Build a category-by-region summary report

Extend Week 5's tables with the category column and answer five real questions.

Requirements:

  1. ALTER TABLE orders ADD category (or recreate the table) and load the 7-row extended dataset shown above.
  2. Write a query grouping by both region and category, showing total amount and order count for each combination.
  3. Add a HAVING clause to that query keeping only groups with more than one order.
  4. Write a single query using CASE WHEN inside SUM to show Electronics total, Clothing total, and grand total as three separate columns, one row per region.
  5. Rewrite one of your earlier queries as a WITH CTE followed by a simple SELECT from it, and format the whole thing with one clause per line.
Hint

For step 4, remember the ELSE 0 inside each CASE WHEN — without it, a non-matching row contributes NULL instead of 0, and SUM ignoring NULLs (Week 2) can make a column's total look right by accident even when the logic is subtly wrong.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Given GROUP BY region, category, what determines whether two rows end up in the same group?

Two rows land in the same group only if they match on every listed column — the same region AND the same category. A North/Electronics row and a North/Clothing row are different groups, even though they share the same region, because the category differs.

Q2

What does SUM(CASE WHEN category = 'Electronics' THEN amount ELSE 0 END) compute for a row where category is "Clothing"?

The CASE WHEN evaluates to 0 for that row, since the condition (category = 'Electronics') is false — it contributes nothing to the sum. Only rows where the condition is true contribute their actual amount, which is exactly what makes the surrounding SUM a per-category total.

Q3

What's the difference between COUNT(*) and COUNT(DISTINCT customer_id) in a grouped query?

COUNT(*) counts every row in the group, regardless of repeats — three orders from the same customer count as 3. COUNT(DISTINCT customer_id) counts only unique customer IDs, so those same three orders from one customer count as 1. The gap between the two reveals how many repeat orders are hiding inside a simple row count.

Q4

What does wrapping a query in WITH regional_totals AS (...) actually change about what it returns?

Nothing about the final result changes — a CTE is purely a readability tool that names an intermediate result so a later part of the query (or a person reading it) can refer to it by a clear name instead of a repeated, nested subquery. The output is identical to writing the same logic as one unnamed, more tangled query.