Joins, Aggregation & Subqueries
The pizza shop with two notebooks 🍕
A pizza shop keeps two notebooks. One lists the customers (number, name). The other lists the orders, and each order only says "customer #3 paid 25.00". The boss asks: "How much did each customer spend this month? And who has not ordered at all?"
You flip between the notebooks, matching customer numbers: that is a join. You add up each customer's orders: that is aggregation. You spot the names that never show up in the order notebook: that is the job of an outer join or a subquery.
Last lesson you learned to split data into separate tables so every fact lives once. This lesson is the other half: putting it back together whenever you ask a question.
What you'll learn
INNER JOINandLEFT JOIN, and when each one is right- Why a filter in
ONand a filter inWHEREbehave differently on a left join - Summaries with
COUNT,SUM,AVG,MIN,MAX,GROUP BYandHAVING - The trap of
COUNT(*)versusCOUNT(column) - Subqueries,
EXISTSand CTEs (WITH), and whyNOT INcan silently return nothing
In the labs you keep writing SQL for SQLite inside Python strings.
Joins: matching rows across tables
Take these two tables:
-- customers: (1, Amal) (2, Bilal) (3, Carla)
-- orders: (id 1, customer_id 1, 2500) (id 2, customer_id 1, 900) (id 3, customer_id 2, 4100)
SELECT c.name, o.id, o.total_cents
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id;
FROM customers AS c JOIN orders AS o ON ... says: pair every customer row with every order row, and keep only the pairs where the ON condition is true. The short names c and o (aliases) save typing and tell columns apart.
The result is one row per matching pair: Amal appears twice (two orders), Bilal once, and Carla does not appear at all because no order matched her. That is an INNER JOIN (plain JOIN means the same thing).
To keep every customer, use a LEFT JOIN: keep every row from the left table, and fill the right-side columns with NULL when nothing matches.
SELECT c.name, o.id, o.total_cents
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id;
-- Carla now appears once, with o.id = NULL and o.total_cents = NULL
The ON versus WHERE trap
Suppose you want every customer plus only their orders of 1000 or more.
-- WRONG: Carla is kept by the join, then WHERE throws her away (NULL >= 1000 is not true).
... LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.total_cents >= 1000
-- RIGHT: the filter is part of the matching rule, so unmatched customers survive.
... LEFT JOIN orders o ON o.customer_id = c.id AND o.total_cents >= 1000
ON decides which rows match. WHERE runs after the join and removes finished rows. On an inner join the two are equivalent, but on a left join they are not.
Two more things worth knowing
- Forgetting the join condition makes a cross join: every row of one table paired with every row of the other. 3 customers and 4 orders give 12 rows, and 10,000 by 10,000 gives 100 million. If a query is suddenly slow and huge, look for a missing
ON. - RIGHT JOIN is a LEFT JOIN with the tables swapped. Most people simply write a LEFT JOIN with the main table first.
An inner join prints only the matching pairs, so Carla vanishes. The left join adds one extra row for a left row that matched nothing, with None (NULL) on the right. Real databases use smarter strategies than nested loops, but the result is the same.
# What a join really does, with two nested loops.
customers = [(1, "Amal"), (2, "Bilal"), (3, "Carla")]
orders = [(1, 1, 2500), (2, 1, 900), (3, 2, 4100)] # (id, customer_id, total_cents)
print("INNER JOIN")
for cid, name in customers:
for oid, customer_id, total in orders:
if customer_id == cid: # the ON condition
print(" ", name, oid, total)
print("LEFT JOIN")
for cid, name in customers:
matched = False
for oid, customer_id, total in orders:
if customer_id == cid:
matched = True
print(" ", name, oid, total)
if not matched: # keep the left row, fill the right side with NULL
print(" ", name, None, None)Lab exercise 1
PythonGraded in Code LabThe tables customers and orders exist. For a minimum total given on stdin (in cents), list every customer with each of their orders whose total is at least that minimum. A customer with no qualifying order still appears once.
Fix report_sql so it selects c.name, o.id, o.total_cents ordered by customer name, then order id. The query may use the named parameter :min_total. The program prints name: order ID (TOTAL) or name: no matching order.
Input: one integer, the minimum total in cents.
Example: the minimum 1000 prints, among other lines, Amal: order 1 (25.00) and Carla: no matching order.
- Minimum 0: every order, customers without orders kept
- Minimum 1000: small orders drop out, their customers stay
- Boundary: an order equal to the minimum is included
- Minimum above every order: all customers, no matches
Example run
- Input
0
- Expected output
Amal: order 1 (25.00) Amal: order 2 (9.00) Amal: order 5 (7.00) Bilal: order 3 (41.00) Carla: no matching order Dina: order 4 (15.00) Dina: order 6 (3.00) Emad: no matching order
import sqlite3
import sys
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
total_cents INTEGER NOT NULL
);
INSERT INTO customers (id, name) VALUES (1, 'Amal'), (2, 'Bilal'), (3, 'Carla'), (4, 'Dina'), (5, 'Emad');More lines in Code Lab: 22
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Aggregation: from many rows to one answer
An aggregate function reads many rows and returns one value:
| Function | Meaning | |---|---|
Since tables are not supported here, think of the five you will use daily: COUNT (how many), SUM (total), AVG (mean), MIN and MAX.
SELECT COUNT(*), SUM(total_cents), MAX(total_cents) FROM orders; -- one row for the whole table
Add GROUP BY to get one row per group instead:
SELECT customer_id, COUNT(*) AS orders, SUM(total_cents) AS spent
FROM orders
GROUP BY customer_id;
Rule of thumb: every column in SELECT is either listed in GROUP BY or wrapped in an aggregate function. Most databases enforce it, because "which of the group's ten values should I show?" has no answer.
WHERE versus HAVING
Think of the order in which the database works:
FROM/JOIN: build the rowsWHERE: remove rows (before grouping)GROUP BY: form the groupsHAVING: remove groups (after aggregating)SELECT, thenORDER BY, thenLIMIT
SELECT dept_id, COUNT(*) AS headcount, ROUND(AVG(salary), 1) AS avg_salary
FROM employees
WHERE salary >= 3000 -- employees below 3000 are never counted
GROUP BY dept_id
HAVING COUNT(*) >= 2 -- departments with fewer than 2 remaining employees disappear
ORDER BY avg_salary DESC;
WHERE COUNT(*) >= 2 is an error: when WHERE runs, no group exists yet.
COUNT(*) versus COUNT(column)
COUNT(*) counts rows. COUNT(column) counts rows where that column is not NULL. All the other aggregates (SUM, AVG, MIN, MAX) also ignore NULLs.
This matters on a left join. Carla has no orders, but the join still produced one row for her (with NULLs):
SELECT c.name, COUNT(*) AS wrong, COUNT(o.id) AS right
FROM customers c LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id;
-- Carla: wrong = 1, right = 0
Also note that SUM of zero rows is NULL, not 0. Wrap it: COALESCE(SUM(x), 0) gives 0 when nothing matched.
Only Engineering survives: WHERE removed the low and unknown salaries before grouping, Design is left with one employee, and Support with one, so HAVING dropped both. Aggregates in SQL skip NULL the same way the salary !== null test does here.
// GROUP BY in plain JavaScript: WHERE, then group, then HAVING.
const staff = [
{ name: "Hana", dept: "Engineering", salary: 5200 },
{ name: "Idris", dept: "Engineering", salary: 4800 },
{ name: "Lina", dept: "Design", salary: 4100 },
{ name: "Nabil", dept: "Support", salary: 2800 },
{ name: "Omar", dept: "Support", salary: 3000 },
{ name: "Pia", dept: "Support", salary: null }, // unknown salary
];
const kept = staff.filter((s) => s.salary !== null && s.salary >= 3000); // WHERE
const groups = new Map(); // GROUP BY dept
for (const s of kept) {
if (!groups.has(s.dept)) groups.set(s.dept, []);
groups.get(s.dept).push(s.salary);
}
for (const [dept, salaries] of groups) {
if (salaries.length < 2) continue; // HAVING COUNT(*) >= 2
const avg = salaries.reduce((a, b) => a + b, 0) / salaries.length;
console.log(dept, "headcount", salaries.length, "avg", avg);
}Lab exercise 2
PythonGraded in Code LabTables departments and employees exist. Write report_sql to summarize each department:
- only count employees with
salary >= :min_salary(a WHERE filter, applied before grouping) - only show departments with at least
:min_headcountsuch employees (a HAVING filter) - select the department name, the headcount, the average salary and the highest salary (in that order)
- order by average salary, highest first, then by department name
Input: two integers: <min headcount> <min salary>.
Output: the program prints Name: headcount=N avg=A max=M per department, or no department qualifies.
Example: input 3 3000 prints Engineering: headcount=4 avg=5150.0 max=6100 only (Design has 2 qualifying employees, Support has 2).
- No real filter: every department with employees
- Salary filter shrinks the groups before counting
- HAVING removes small groups
- Single employee above a high salary bar
- Nothing qualifies
Example run
- Input
1 0
- Expected output
Engineering: headcount=4 avg=5150.0 max=6100 Design: headcount=2 avg=4000.0 max=4100 Support: headcount=5 avg=2900.0 max=3100
import sqlite3
import sys
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE employees (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
dept_id INTEGER NOT NULL REFERENCES departments(id),
salary INTEGER NOT NULL
);More lines in Code Lab: 24
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Subqueries, EXISTS and CTEs
A subquery is a query inside another query. It can produce a single value, a list, or a whole table.
-- A single value: employees paid more than the company average
SELECT name FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);
-- A list: customers who placed at least one order
SELECT name FROM customers
WHERE id IN (SELECT customer_id FROM orders);
A correlated subquery mentions a column of the outer query, so it is re-evaluated for each outer row. This finds employees who earn more than their own department's average:
SELECT e.name
FROM employees e
WHERE e.salary > (SELECT AVG(x.salary) FROM employees x WHERE x.dept_id = e.dept_id);
Readable steps with a CTE
A CTE (common table expression, WITH) gives a subquery a name, so a long query reads top to bottom:
WITH dept_avg AS (
SELECT dept_id, AVG(salary) AS avg_salary FROM employees GROUP BY dept_id
)
SELECT e.name, e.salary, d.avg_salary
FROM employees e JOIN dept_avg d ON d.dept_id = e.dept_id
WHERE e.salary > d.avg_salary;
"Who has none?" and the NOT IN trap
To find rows with no match, the natural query is NOT IN:
SELECT name FROM products
WHERE id NOT IN (SELECT product_id FROM order_items); -- dangerous!
If even one product_id in the subquery is NULL, this returns no rows at all. Why? id NOT IN (1, 2, NULL) means id <> 1 AND id <> 2 AND id <> NULL. The last comparison is unknown, never true, so the whole condition can never be true. There is no error, only a silently empty answer.
Two safe forms:
-- 1. NOT EXISTS: does a matching row exist? NULLs never match, so they cause no harm.
SELECT p.name FROM products p
WHERE NOT EXISTS (SELECT 1 FROM order_items i WHERE i.product_id = p.id);
-- 2. LEFT JOIN, then keep the rows where the right side is missing.
SELECT p.name FROM products p
LEFT JOIN order_items i ON i.product_id = p.id
WHERE i.id IS NULL;
EXISTS only asks "is there at least one row?", so what the inner SELECT returns does not matter (by convention SELECT 1). Use NOT EXISTS or the LEFT JOIN form, and keep NOT IN for fixed lists such as NOT IN ('a', 'b').
Lab exercise 3
PythonGraded in Code LabTables products and order_items exist. One order line is a custom item with product_id = NULL. List the products that were never ordered, optionally limited to one category.
Write report_sql so it selects the product name of every product with no order line, in alphabetical order. It may use the named parameter :category: when it is *, every category is included, otherwise only products of that category.
Input: a category name, or *.
Output: the names joined by , , or (none).
Example: input office prints Stapler (the Marker was ordered).
- All categories
- One category with a single unordered product
- Stationery: only the planner is unordered
- A category whose products were all ordered
- Unknown category
Example run
- Input
*
- Expected output
Lamp, Planner, Stapler
import sqlite3
import sys
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL);
-- product_id is NULL for a custom item that is not in the catalog.
CREATE TABLE order_items (id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL, product_id INTEGER);
INSERT INTO products (id, name, category) VALUES
(1, 'Notebook', 'stationery'), (2, 'Pen', 'stationery'), (3, 'Stapler', 'office'),
(4, 'Marker', 'office'), (5, 'Planner', 'stationery'), (6, 'Lamp', 'home'), (7, 'Kettle', 'kitchen');
INSERT INTO order_items (order_id, product_id) VALUESMore lines in Code Lab: 18
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Question 1 of 5
Customers is the left table and orders the right one. Which join returns a customer who has never placed an order?
Question 2 of 5
After a LEFT JOIN, Carla has no orders. What do COUNT(*) and COUNT(o.id) return for her group?
Question 3 of 5
You want departments with more than 5 employees. Where does the condition go?
Question 4 of 5
products has ids 1 to 5, and order_items.product_id contains the values 1, 2 and NULL. What does SELECT id FROM products WHERE id NOT IN (SELECT product_id FROM order_items) return?
Question 5 of 5
Two tables hold 3 customers and 4 orders. You write FROM customers, orders (or a JOIN with no ON condition) and nothing else. How many rows come back?
Recap and what to practice next
- A join matches rows through a condition. INNER keeps matched pairs only; LEFT keeps every left row and fills the right with NULL.
- On a left join, put filters about the right table in ON; a WHERE on it removes the NULL rows again.
- GROUP BY makes one row per group. WHERE filters rows before grouping, HAVING filters groups after.
- COUNT(*) counts rows, COUNT(column) counts non-NULL values, and aggregates ignore NULL.
SUMof nothing is NULL: useCOALESCE. - Subqueries and CTEs break a question into steps. Prefer NOT EXISTS over
NOT INwhen NULLs are possible.
Practice next: take the two tables from the first lab and answer new questions: the customer with the highest total, the average order per customer, customers whose every order is above 1000 (hint: compare MIN). Then read a query plan: the next lesson shows why a join on a big table is fast with the right index and painfully slow without one.
Practice
Reading is not learning until you run something. Pick one.
Graded lab exercises
- Lab exercise 1PythonNot passed yet Try in Code Lab
- Lab exercise 2PythonNot passed yet Try in Code Lab
- Lab exercise 3PythonNot passed yet Try in Code Lab