learn-sql-with-phoebe / Builder session 3 of 10
Learn SQL with Phoebe · Builder track · Session 3 of 10

Aggregating: COUNT, SUM, GROUP BY

So far every query returned rows - one line per record. But most real questions want a number: how many orders, total revenue, average price, customers per country. Aggregation collapses many rows into a summary, and GROUP BY lets you get one summary per group. This is the session where SQL stops listing data and starts answering business questions. Same Daybreak database, same live editor.

🟡 Builder track Practitioners: analysts · DE · DS · PMs Runs in your browser · SQLite ~45 min
0-3 · Welcome 3-20 · Aggregates & GROUP BY 20-42 · Build-along: revenue per product 42-45 · Q&A
Part 0

From rows to answers

Sessions b1 and b2 gave you rows - filtered, sorted, but still one line per record. Leadership rarely wants 33 order rows; they want "how many orders?" and "how much revenue?". Aggregate functions squeeze many rows into a single number, and GROUP BY repeats that squeeze once per group so you get "orders per country" or "average price per category". That is the leap from data to insight.

Live - presented in session Self-study - read after class ▶ Live SQL - editable & runnable Official sources covered
★ What you walk out with today The five aggregate functions (COUNT, SUM, AVG, MIN, MAX), GROUP BY to compute them per group, HAVING to filter those groups, and DISTINCT to count unique values. You will finish by computing revenue per product on the real order data.
Part 1 · covers Mode "Intermediate - Aggregate functions", SQLBolt L10, Kaggle "Group By, Having & Count"

Aggregate functions 7 min live

An aggregate function takes a whole column of values and returns one number. COUNT tallies rows, SUM adds them up, AVG averages, MIN and MAX find the extremes. Applied to a whole table with no grouping, they collapse every row into a single summary line - the simplest form of a report.

COUNT(*) counts rows SUM(x) column total AVG(x) column mean MIN / MAX(x) min or max Every aggregate takes many rows and returns exactly one number - the shape behind every KPI tile.
🔍 Click to zoom - many rows go in, one number comes out, every time
FunctionReturnsExample question
COUNT(*)how many rows"How many orders do we have?"
SUM(x)total of a column"What is total revenue?"
AVG(x)the mean of a column"What is our average product price?"
MIN(x) / MAX(x)the smallest / largest value"Cheapest and priciest product?"
LiveCOUNT - how many rows2 min

COUNT(*) returns the number of rows, collapsing the whole table to one number. The AS orders renames the output column so the result reads cleanly. Press ▶ Run, then change orders to customers or products.

SELECT COUNT(*) AS orders
FROM orders;
One number, not a list Notice the result is a single cell, not 33 rows. That is the signature of an aggregate: many rows in, one value out. No GROUP BY yet means "the whole table is one group".
LiveAVG - the average, rounded3 min

AVG(price) averages the price column. Raw averages spill long decimals, so wrap it in ROUND(..., 2) to keep two places - a small habit that makes reports readable. Change the 2 to 0 to round to whole dollars.

SELECT ROUND(AVG(price),2) AS avg_price
FROM products;
LiveMIN and MAX together2 min

You can compute several aggregates in one query - here the cheapest and priciest product side by side. Both scan the same price column and each returns one value, so the result is a single tidy row.

SELECT MIN(price) AS cheapest, MAX(price) AS priciest
FROM products;
Real world

The "one big number" dashboard tile. Every KPI card you have ever seen - total revenue, active users, average order value - is one aggregate function under the hood. When a stakeholder asks "just give me the headline number", they are asking for a SUM, a COUNT, or an AVG. You already know how to write all three.

Part 2 · covers Mode "GROUP BY", SQLBolt L11, Kaggle "Group By"

GROUP BY - per what? 7 min live

One number for the whole table is a start, but the real questions are "per country", "per category", "per month". GROUP BY sorts rows into buckets by a column, then runs the aggregate once per bucket. The rule of thumb: whatever you put after GROUP BY is the "per what" of your question.

15 customer rows USA Canada USA UK USA Canada GROUP BY country USAUSAUSA CanadaCanada UK one row per country USA · COUNT = 3 Canada · COUNT = 2 UK · COUNT = 1 SELECT country, COUNT(*) FROM customers GROUP BY country; Bucket the rows by country, then count each bucket. Grouping column + aggregate = one row per group. Rule: every non-aggregated column in SELECT should appear in GROUP BY.
🔍 Click to zoom - GROUP BY buckets rows, then aggregates each bucket
LiveCount customers per country3 min

This is the diagram, live. Group by country, count the rows in each group, and sort so the biggest markets sit on top. The output has one row per country - exactly what "per country" promised.

SELECT country, COUNT(*) AS customers
FROM customers
GROUP BY country
ORDER BY customers DESC;
The golden rule Every column in your SELECT that is not inside an aggregate must appear in GROUP BY. Here country is grouped and COUNT(*) is aggregated - balanced. Break the rule and other databases error; SQLite guesses, which is worse.
LiveAverage price per category3 min

Swap COUNT for AVG and you get the mean price within each product category - one line for Coffee, one for Equipment, one for Add-on. This single query would take a pivot table and several clicks in a spreadsheet.

SELECT category, ROUND(AVG(price),2) AS avg_price
FROM products
GROUP BY category;
Part 3 · covers Mode "HAVING, DISTINCT", SQLBolt L11, Kaggle "Having & Count"

HAVING and DISTINCT 6 min live

Two finishing tools. HAVING filters groups after aggregation - the counterpart to WHERE, which filters rows before. And DISTINCT strips duplicates, which is how you count unique things: how many different countries, not how many customers.

WHERE checks rows first; HAVING checks groups last All rows WHERE filters each row GROUP BY buckets by column HAVING filters whole groups Result WHERE filters individual rows before grouping; HAVING filters whole groups after grouping. Put an aggregate in WHERE and there is nothing to test yet - that is what HAVING is for.
🔍 Click to zoom - filter rows with WHERE, filter groups with HAVING, in that order
ClauseFilters...Runs...
WHEREindividual rowsbefore grouping - "only USA customers"
HAVINGwhole groupsafter grouping - "only groups with 3+ members"
LiveHAVING - filter groups after aggregating3 min

You cannot put an aggregate in WHERE - the counts do not exist yet when WHERE runs. HAVING comes after grouping, so it can. This keeps only the plans with three or more customers. Change 3 to 10 and watch groups drop out.

SELECT plan, COUNT(*) AS n
FROM customers
GROUP BY plan
HAVING COUNT(*) >= 3;
Real world

"Show me products that sold more than 100 units." That is GROUP BY product then HAVING SUM(quantity) > 100. Any request of the form "groups that meet a threshold" - high-volume SKUs, cities with many complaints, reps over quota - is a HAVING clause. Mixing it up with WHERE is the most common aggregation bug, so the order matters: filter rows with WHERE, filter groups with HAVING.

LiveDISTINCT - count the unique values3 min

COUNT(DISTINCT country) counts how many different countries appear, ignoring repeats. Plain COUNT(country) would count every customer; the DISTINCT is what turns it into "how many markets do we operate in".

SELECT COUNT(DISTINCT country) AS countries
FROM customers;
Build-along

Revenue per product, from the line items ★ 14 min · everyone builds

Daybreak's founder asks the big one: "Which products make us the most money?" Revenue lives in order_items, where each line is a quantity times a unit_price. You have not learned joins yet (that is b4), so we will stay inside one table and aggregate by product_id. It is the most useful query in the whole session.

See the raw material: SELECT * FROM order_items LIMIT 8; - each row is one product line, with quantity and unit_price.

Compute line revenue: quantity * unit_price. That is money for a single line.

Total it per product: SUM(quantity * unit_price) with GROUP BY product_id - one revenue figure per product.

Rank it: ORDER BY revenue DESC puts the top earners first. That is the founder's answer.

SELECT product_id, SUM(quantity*unit_price) AS revenue
FROM order_items
GROUP BY product_id
ORDER BY revenue DESC;
Real world

This is the revenue report. "Top products by revenue" is one of the most-run queries in any commerce business, and you just wrote it in four lines. The only thing missing is the product name instead of the id - and that is a single join away in session b4. The hard part, the aggregation logic, is already done.

Your turn

Three questions, your queries ★ 10 min · build your own

No scaffolding now. Each editor answers one aggregation question - run it, tweak it, break it, fix it. The run-fix-run loop is the whole skill.

LiveQ1 · How many orders came through each channel?3 min

Group the orders by channel (web vs app) and count each bucket. One row per channel comes back.

SELECT channel, COUNT(*) AS orders
FROM orders
GROUP BY channel
ORDER BY orders DESC;
LiveQ2 · How many products are in each category?3 min

Group products by category and count. This tells you where the catalog is deep and where it is thin.

SELECT category, COUNT(*) AS products
FROM products
GROUP BY category
ORDER BY products DESC;
Self-studyQ3 · How many distinct customers placed orders?2 min

The orders table has 33 rows but fewer distinct customers - some people ordered more than once. COUNT(DISTINCT customer_id) counts the unique buyers.

SELECT COUNT(DISTINCT customer_id) AS buyers
FROM orders;
Homework

Try it yourself - this week ◐ 20-30 min total

Source material

Official sources covered

This session teaches the aggregation core of the major free SQL curricula, run on live Daybreak data instead of screenshots. Certificates and graded problem sets stay on the official sites (linked below). This page covers:

Mode SQL Tutorial - Intermediate: aggregate functions, COUNT/SUM/MIN/MAX/AVG, GROUP BY, HAVING, DISTINCTParts 1-3 · the full aggregation toolkit, run live
SQLBolt Lessons 10-11 - aggregate queries & filtering grouped rowsParts 1-3 · same functions and GROUP BY, editable here
Kaggle - Intro to SQL: "Group By, Having & Count"Parts 2-3 · GROUP BY + HAVING; Kaggle's exercises stay on Kaggle
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What does GROUP BY do?

GROUP BY buckets rows by a column and returns one row per bucket, letting COUNT/SUM/AVG run once per group. Sorting is ORDER BY; de-duping is DISTINCT.

2 · What is the difference between WHERE and HAVING?

WHERE runs first on individual rows; HAVING runs after grouping and can test aggregates like COUNT(*). That order is why HAVING can see counts and WHERE cannot.

3 · What does COUNT(DISTINCT country) return?

DISTINCT strips duplicates first, so COUNT(DISTINCT country) counts how many different countries appear - the number of unique values, not total rows.

Builder session 3 cheat sheet · pin this

AggregatesCOUNT, SUM, AVG, MIN, MAX. Many rows in, one number out.
COUNT(*)Counts rows. SELECT COUNT(*) FROM orders = how many orders.
GROUP BYThe "per what". GROUP BY country = one summary row per country.
Golden ruleEvery non-aggregated SELECT column must appear in GROUP BY.
HAVINGFilters groups after aggregating. HAVING COUNT(*) >= 3.
WHERE vs HAVINGWHERE filters rows before grouping; HAVING filters groups after.
DISTINCTCounts unique values. COUNT(DISTINCT country) = number of markets.
Running projectYou can now report on Daybreak. Next: b4 joins tables to add names to the numbers.