Lesson 9 of 16 · Level 3: Advanced
Intermediate SQL: GROUP BY, HAVING and JOINs
- ⏱ 12 min read
- ❓ 4 review questions
- ✍️ Exercise with solution
- 📥 Downloadable template
In this lesson
- How to summarize data with aggregate functions: COUNT, SUM, AVG, MIN and MAX.
- How GROUP BY creates one row per group, including groups of two columns.
- The difference between WHERE and HAVING, and the order in which SQL actually runs a query.
- How INNER JOIN and LEFT JOIN combine tables, with gross profit by category as a real example.
- The JOIN and GROUP BY traps that silently double-count or hide data.
Use the sales and products tables you loaded in Lesson 8. If you skipped it, import these two files into any SQL tool:
- sales-practice-data-clean.csv · table
sales - sales-practice-products.csv · table
products
In the last lesson you filtered and sorted individual rows. Real questions are usually about groups: revenue per region, orders per channel, profit per category. That's what GROUP BY does. And because the information you need is often split across tables (sales in one, product costs in another), you also need JOINs. These two tools cover the majority of everyday analyst SQL and most SQL interview questions. Every result below comes from the practice tables.
Aggregate functions
An aggregate function turns many rows into one value:
SELECT COUNT(*) AS orders,
SUM(revenue) AS revenue,
ROUND(AVG(revenue), 2) AS avg_order,
MIN(revenue) AS smallest,
MAX(revenue) AS largest
FROM sales;
| orders | revenue | avg_order | smallest | largest |
|---|---|---|---|---|
| 200 | 89735 | 448.68 | 35 | 5229 |
Same numbers as the spreadsheet in Lesson 6: a good sign your data loaded correctly. COUNT(*) counts rows; COUNT(column) counts only non-NULL values in that column, a difference that matters with LEFT JOINs later in this lesson.
GROUP BY: one row per group
Add GROUP BY and the aggregates are calculated separately for each group:
SELECT region,
COUNT(*) AS orders,
SUM(revenue) AS revenue,
ROUND(AVG(revenue), 2) AS avg_order
FROM sales
GROUP BY region
ORDER BY revenue DESC;
| region | orders | revenue | avg_order |
|---|---|---|---|
| West | 64 | 36626 | 572.28 |
| Northeast | 45 | 20786 | 461.91 |
| South | 59 | 18684 | 316.68 |
| Midwest | 32 | 13639 | 426.22 |
This is the SQL version of a pivot table with region in Rows. Group by two columns (GROUP BY region, channel) and you get one row per combination: 4 regions × 3 channels = 12 rows, such as West / Wholesale = 28,131 and South / Retail = 4,497.
Every column in SELECT must either be in GROUP BY or be inside an aggregate function. SELECT region, product, SUM(revenue) FROM sales GROUP BY region is an error in PostgreSQL, SQL Server and BigQuery, because each region has many products and SQL doesn't know which to show. SQLite runs it anyway and shows an arbitrary product, which is worse: the output looks fine but the product column means nothing.
1. Which query correctly returns revenue per product?
Group by the category you want one row for (product) and aggregate the measure (SUM of revenue). The first query selects revenue without aggregating it; the third sums a text column and groups by the wrong field.
WHERE vs. HAVING
Both filter, but at different moments:
- WHERE filters rows before grouping. It can't use aggregates.
- HAVING filters groups after grouping. It's where conditions like
SUM(revenue) > 10000go.
Products that brought in more than $10,000:
SELECT product, SUM(units) AS units, SUM(revenue) AS revenue
FROM sales
GROUP BY product
HAVING SUM(revenue) > 10000
ORDER BY revenue DESC;
| product | units | revenue |
|---|---|---|
| Smart Watch | 118 | 23482 |
| Wireless Earbuds | 162 | 12798 |
| Office Chair | 48 | 11952 |
| Adjustable Dumbbells | 35 | 10465 |
You can use both in one query. "In the West, revenue by channel":
SELECT channel, COUNT(*) AS orders, SUM(revenue) AS revenue
FROM sales
WHERE region = 'West' -- row filter, before grouping
GROUP BY channel
ORDER BY revenue DESC;
| channel | orders | revenue |
|---|---|---|
| Wholesale | 14 | 28131 |
| Online | 30 | 5593 |
| Retail | 20 | 2902 |
Fourteen wholesale orders out-earn the other 50 West orders combined ($8,495) by more than three to one.
2. You want the categories whose average order is above $450. Where does the condition go?
The condition is on an aggregate of each group, so it belongs in HAVING. WHERE revenue > 450 would drop small orders before averaging (a different question), and WHERE can't contain AVG. The result: Electronics ($579.84), Fitness ($511.88) and Office ($469.91).
The order SQL runs a query
You write SELECT first, but the database runs the clauses in a different order. Knowing it explains most error messages:
- FROM / JOINGet the rowsLoad the table(s) and combine them.
- WHEREFilter rowsDrop rows that don't match. Aliases from SELECT don't exist yet.
- GROUP BYForm groupsBundle rows that share the same values.
- HAVINGFilter groupsKeep groups whose aggregates meet the condition.
- SELECTCalculate columnsCompute expressions and aggregates, assign aliases.
- ORDER BY / LIMITSort and trimAliases can be used here, because SELECT already ran.
That's why WHERE avg_order > 450 fails (the alias doesn't exist when WHERE runs) while ORDER BY revenue DESC using an alias works fine.
JOINs: combining tables
The sales table doesn't know what each product costs. The products table does. A JOIN matches rows from both tables using a common column, here product.
INNER JOIN: gross profit by category
SELECT s.category,
SUM(s.revenue) AS revenue,
SUM(s.units * p.unit_cost) AS cost,
SUM(s.revenue) - SUM(s.units * p.unit_cost) AS gross_profit,
ROUND(100.0 * (SUM(s.revenue) - SUM(s.units * p.unit_cost))
/ SUM(s.revenue), 1) AS margin_pct
FROM sales AS s
INNER JOIN products AS p
ON s.product = p.product
GROUP BY s.category
ORDER BY gross_profit DESC;
| category | revenue | cost | gross_profit | margin_pct |
|---|---|---|---|---|
| Electronics | 44068 | 22936 | 21132 | 48.0 |
| Home & Kitchen | 17875 | 9128 | 8747 | 48.9 |
| Office | 15507 | 8269 | 7238 | 46.7 |
| Fitness | 12285 | 6888 | 5397 | 43.9 |
s and p are table aliases, short names that tell SQL which table each column comes from (both tables have product, category and unit_price). Multiplying by 100.0 instead of 100 forces decimal division; in SQLite, dividing two integers drops the decimals.
Fitness has the lowest margin (43.9%) because adjustable dumbbells cost $176 to buy and sell for $299. That's the kind of insight you can't get from the sales table alone.
LEFT JOIN: find what's missing
An INNER JOIN keeps only rows that match in both tables. A LEFT JOIN keeps every row from the left table, and fills the right side with NULL when there's no match. That makes it perfect for finding gaps, such as products that never sold:
SELECT p.product,
COUNT(s.order_id) AS orders,
COALESCE(SUM(s.revenue), 0) AS revenue
FROM products AS p
LEFT JOIN sales AS s
ON p.product = s.product
GROUP BY p.product
ORDER BY revenue ASC
LIMIT 3;
| product | orders | revenue |
|---|---|---|
| Desk Lamp | 0 | 0 |
| Yoga Mat | 13 | 1820 |
| Monitor Stand | 21 | 3555 |
The Desk Lamp is in the catalog but had zero sales in 2025. An INNER JOIN would have hidden it completely. COALESCE replaces the NULL sum with 0, and COUNT(s.order_id) counts only real matches; COUNT(*) would count the Desk Lamp's NULL row and wrongly show 1 order.
- Rows here
- 200 (every sale has a product)
- Use it for
- Adding attributes or costs to records you know exist in both tables.
- Rows here
- 201 (products → sales: 200 matches + the unsold Desk Lamp)
- Use it for
- Finding gaps: customers with no orders, products with no sales.
- Rows here
- Same as a LEFT JOIN with the tables swapped
- Use it for
- Rarely needed; most analysts rewrite it as a LEFT JOIN.
- Rows here
- All matches plus unmatched rows from both sides
- Use it for
- Reconciling two lists, such as two systems' customer files.
3. products LEFT JOIN sales returns 201 rows, while sales INNER JOIN products returns 200. Where does the extra row come from?
Each of the 200 sales matches exactly one product. The LEFT JOIN also keeps every product from the left table, including the one with no match, so you get 200 + 1 = 201 rows.
JOIN traps that change your totals
Joining to a lookup table with duplicate keys (e.g., "Smart Watch" listed twice with two costs).
Check that the key is unique in the lookup table. Each duplicate multiplies matching sales rows and inflates totals.
Not comparing row counts before and after a join.
Count first. Here, SUM(s.revenue) after the join must still be 89,735 with 200 rows.
Joining on messy text ("Texas " vs. "Texas").
Clean keys first (Lesson 5). Unmatched spellings drop out of an INNER JOIN silently.
Filtering the right table in WHERE after a LEFT JOIN.
WHERE s.channel = 'Online' removes the NULL rows and turns your LEFT JOIN into an INNER JOIN. Put that condition in the ON clause instead.
4. In the LEFT JOIN query above, what would COUNT(*) return for the Desk Lamp?
The LEFT JOIN produces one row for the Desk Lamp with NULLs on the sales side. COUNT(*) counts rows, so it returns 1. COUNT(s.order_id) skips NULLs and correctly returns 0 orders.
Practice exercise
- Write a query that shows, for the South region only, each state's number of orders and revenue, keeping only states with more than $4,000 in revenue, from highest to lowest.
- Write a query that joins sales with products and returns revenue, gross profit and margin % by channel.
See solution
SELECT state, COUNT(*) AS orders, SUM(revenue) AS revenue
FROM sales
WHERE region = 'South'
GROUP BY state
HAVING SUM(revenue) > 4000
ORDER BY revenue DESC;
| state | orders | revenue |
|---|---|---|
| Texas | 25 | 8489 |
| Florida | 21 | 7435 |
WHERE filters the rows to the South before grouping; HAVING drops North Carolina ($2,280) and Georgia ($480) after grouping.
SELECT s.channel,
SUM(s.revenue) AS revenue,
SUM(s.units * (s.unit_price - p.unit_cost)) AS gross_profit,
ROUND(100.0 * SUM(s.units * (s.unit_price - p.unit_cost))
/ SUM(s.revenue), 1) AS margin_pct
FROM sales AS s
INNER JOIN products AS p ON s.product = p.product
GROUP BY s.channel
ORDER BY gross_profit DESC;
| channel | revenue | gross_profit | margin_pct |
|---|---|---|---|
| Wholesale | 61479 | 29236 | 47.6 |
| Online | 15861 | 7456 | 47.0 |
| Retail | 12395 | 5822 | 47.0 |
Margins are almost identical across channels (about 47%), because every channel pays the same price per product. Wholesale matters because of volume, not margin.
Summary
- Aggregate functions (COUNT, SUM, AVG, MIN, MAX) collapse rows into one value; GROUP BY does it per group.
- Every SELECT column must be grouped or aggregated; SQLite won't always warn you.
- WHERE filters rows before grouping; HAVING filters groups after.
- SQL runs FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY, which explains where aliases work.
- INNER JOIN keeps matches (gross profit: $42,514); LEFT JOIN keeps everything on the left and reveals gaps like the unsold Desk Lamp.
- Check row counts and totals before and after every join.
Next lesson: Data Visualization: How to Choose the Right Chart. You'll turn these query results into charts that make the main finding obvious in seconds.
Create your account in 30 seconds, mark your lessons and download your certificate at the end.