Joins, grouping and subqueries
Combine tables with JOIN, summarize data with GROUP BY, and nest queries.
14 min read
You will learn
- Combine related tables with INNER and LEFT JOIN
- Aggregate with COUNT, SUM, AVG, MIN and MAX
- Group rows with GROUP BY and filter groups with HAVING
- Use subqueries
Data is spread across tables
A well-designed database stores each fact once (this is called normalization). An order doesn't repeat the customer's name and email — it stores customer_id, and the customer's details live in customers. To answer real questions, you combine tables again with joins.
customers (id, name, email, city)
│ 1
│ orders (id, customer_id, ordered_at, status)
└──── * │ 1
│ order_items (order_id, product_id, quantity, unit_price)
└──── * │ *
│ products (id, name, category, price, stock)
└──── 1INNER JOIN
Pair up rows that match on both sides:
SELECT o.id, c.name, o.status
FROM orders o
JOIN customers c ON c.id = o.customer_id; -- JOIN means INNER JOINo and c are aliases to keep things short. Rows without a match on either side are dropped.
LEFT JOIN
Keep every row from the left table, filling in NULL where there's no match:
SELECT c.name, o.id AS order_id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id;
-- customers with no orders appear once, with order_id = NULLThat makes "who has never ordered?" easy: add WHERE o.id IS NULL.
Aggregates
| Function | Returns |
|---|---|
COUNT(*) | Number of rows |
COUNT(column) | Number of non-NULL values |
SUM(x), AVG(x) | Total, average |
MIN(x), MAX(x) | Smallest, largest |
SELECT COUNT(*), AVG(price), MAX(price) FROM products;GROUP BY and HAVING
GROUP BY collapses rows that share a value into groups, and aggregates are computed per group:
SELECT category, COUNT(*) AS products, ROUND(AVG(price), 2) AS avg_price
FROM products
GROUP BY category
ORDER BY products DESC;To filter groups, use HAVING (WHERE filters rows before grouping):
SELECT c.name, COUNT(*) AS orders
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.status <> 'cancelled' -- filter rows first
GROUP BY c.id
HAVING COUNT(*) > 1; -- then filter groupsSubqueries
A query can use the result of another query:
-- Products more expensive than average
SELECT name, price FROM products
WHERE price > (SELECT AVG(price) FROM products);
-- Customers who ordered a Laptop
SELECT name FROM customers
WHERE id IN (
SELECT o.customer_id
FROM orders o
JOIN order_items i ON i.order_id = o.id
JOIN products p ON p.id = i.product_id
WHERE p.name = 'Laptop'
);Order of execution
SQL is written SELECT … FROM … WHERE … GROUP BY … HAVING … ORDER BY … LIMIT, but logically evaluated as FROM/JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. That's why you can't use a SELECT alias in WHERE, but you can in ORDER BY.
Practice
SQL sandbox
A real SQLite database running in your browser — a small online shop
For each order, show the order id, the customer's name and the order status.
Hint
JOIN customers ON customers.id = orders.customer_id.Key takeaways
- Normalized data is spread across tables;
JOINrecombines it via keys. INNER JOINkeeps matches only;LEFT JOINkeeps all left rows withNULLs for missing matches.GROUP BY+ aggregates summarize;HAVINGfilters groups,WHEREfilters rows.- Subqueries let one query use another's result.
Quiz
4 questions · 3 correct to pass1.What's the difference between INNER JOIN and LEFT JOIN?
2.What's the difference between WHERE and HAVING?
3.`SELECT category, COUNT(*) FROM products GROUP BY category` returns…
4.Why store order items in a separate table instead of a comma-separated list in the orders table?
To complete this lesson
- ○ Pass the quiz (3/4 correct)