🗄️Databases
Foundations · Lesson 3/8

Joins, Aggregation & Subqueries

45 min 1 quiz Graded labs: 3

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 JOIN and LEFT JOIN, and when each one is right
  • Why a filter in ON and a filter in WHERE behave differently on a left join
  • Summaries with COUNT, SUM, AVG, MIN, MAX, GROUP BY and HAVING
  • The trap of COUNT(*) versus COUNT(column)
  • Subqueries, EXISTS and CTEs (WITH), and why NOT IN can 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 Lab

The 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.

Visible tests (4)
  • 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
Starter code
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

Try in Code Lab

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:

  1. FROM / JOIN: build the rows
  2. WHERE: remove rows (before grouping)
  3. GROUP BY: form the groups
  4. HAVING: remove groups (after aggregating)
  5. SELECT, then ORDER BY, then LIMIT
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 Lab

Tables 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_headcount such 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).

Visible tests (5)
  • 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
Starter code
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

Try in Code Lab

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 Lab

Tables 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).

Visible tests (5)
  • 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
Starter code
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) VALUES

More lines in Code Lab: 18

Try in Code Lab

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.

Quick quiz

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. SUM of nothing is NULL: use COALESCE.
  • Subqueries and CTEs break a question into steps. Prefer NOT EXISTS over NOT IN when 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