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
  1. Aggregate functions
  2. GROUP BY: one row per group
  3. WHERE vs. HAVING
  4. The order SQL runs a query
  5. JOINs: combining tables
  6. JOIN traps that change your totals
  7. Summary
What you'll learn
  • 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.
📥 Practice this lesson

Use the sales and products tables you loaded in Lesson 8. If you skipped it, import these two files into any SQL tool:

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;
ordersrevenueavg_ordersmallestlargest
20089735448.68355229

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;
regionordersrevenueavg_order
West6436626572.28
Northeast4520786461.91
South5918684316.68
Midwest3213639426.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.

The GROUP BY rule

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?

  • SELECT product, revenue FROM sales GROUP BY product;
  • SELECT product, SUM(revenue) AS revenue FROM sales GROUP BY product;
  • SELECT SUM(product), revenue FROM sales GROUP BY revenue;

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) > 10000 go.

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;
productunitsrevenue
Smart Watch11823482
Wireless Earbuds16212798
Office Chair4811952
Adjustable Dumbbells3510465

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;
channelordersrevenue
Wholesale1428131
Online305593
Retail202902

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?

  • WHERE revenue > 450
  • WHERE AVG(revenue) > 450
  • HAVING AVG(revenue) > 450

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:

  1. FROM / JOINGet the rowsLoad the table(s) and combine them.
  2. WHEREFilter rowsDrop rows that don't match. Aliases from SELECT don't exist yet.
  3. GROUP BYForm groupsBundle rows that share the same values.
  4. HAVINGFilter groupsKeep groups whose aggregates meet the condition.
  5. SELECTCalculate columnsCompute expressions and aggregates, assign aliases.
  6. 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;
categoryrevenuecostgross_profitmargin_pct
Electronics44068229362113248.0
Home & Kitchen178759128874748.9
Office155078269723846.7
Fitness122856888539743.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.

Gross profit by category, 2025 (total: $42,514)

Electronics$21,132
Home & Kitchen$8,747
Office$7,238
Fitness$5,397

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;
productordersrevenue
Desk Lamp00
Yoga Mat131820
Monitor Stand213555

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.

INNER JOINOnly matching rows
Rows here
200 (every sale has a product)
Use it for
Adding attributes or costs to records you know exist in both tables.
Most common
LEFT JOINAll rows from the left table
Rows here
201 (products → sales: 200 matches + the unsold Desk Lamp)
Use it for
Finding gaps: customers with no orders, products with no sales.
Finds what's missing
RIGHT JOINAll rows from the right table
Rows here
Same as a LEFT JOIN with the tables swapped
Use it for
Rarely needed; most analysts rewrite it as a LEFT JOIN.
Mirror image
FULL OUTER JOINEverything from both
Rows here
All matches plus unmatched rows from both sides
Use it for
Reconciling two lists, such as two systems' customer files.
For reconciling

3. products LEFT JOIN sales returns 201 rows, while sales INNER JOIN products returns 200. Where does the extra row come from?

  • The Desk Lamp, which has no sales: the LEFT JOIN keeps it with NULLs in the sales columns
  • A duplicate order in the sales table
  • A product with two different costs

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?

  • 0
  • 1
  • NULL

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

  1. 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.
  2. 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;
stateordersrevenue
Texas258489
Florida217435

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;
channelrevenuegross_profitmargin_pct
Wholesale614792923647.6
Online15861745647.0
Retail12395582247.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.

🎓 Save your progress and get a free certificate

Create your account in 30 seconds, mark your lessons and download your certificate at the end.

Enroll for free