Skip to content

Intermediate: combining and changing data · JOINs · topic 23 of 62 · 35 min

Joining three or more tables

Explanation

Real questions often need a chain of tables: customers → orders → order_items → products. You add one JOIN at a time, each with its own ON, and check the row count after each step. Then you can group the joined rows to get revenue per customer or per category.

Following the keys

The question "how much has each customer spent on each category?" touches four tables. The customer's name is in customers, the order status in orders, the quantity and price paid in order_items, and the category in products. Before writing SQL, draw the path between them using the foreign keys.

output
customers.id  <--  orders.customer_id
orders.id     <--  order_items.order_id
products.id   <--  order_items.product_id
The join path of SHOP. order_items sits in the middle, linking orders and products (a many-to-many link table from module 1).

A query with many joins is just this path written down. Each JOIN adds one table and one ON rule that connects it to a table already in the query.

Build it step by step

Do not write a five-table query in one go. Add one table, run it, look at the rows, and only then add the next. If the row count jumps unexpectedly, you know which join caused it.

sql
-- Step 1: orders with customer names (8 rows)
SELECT o.id, c.name
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id;

-- Step 2: add the order lines (12 rows: one per line now)
SELECT o.id, c.name, oi.product_id, oi.qty, oi.price
FROM orders AS o
JOIN customers AS c    ON c.id = o.customer_id
JOIN order_items AS oi ON oi.order_id = o.id;

-- Step 3: add product names and categories (still 12 rows)
SELECT o.id, c.name, p.name AS product, p.category, oi.qty * oi.price AS line_total
FROM orders AS o
JOIN customers AS c    ON c.id = o.customer_id
JOIN order_items AS oi ON oi.order_id = o.id
JOIN products AS p     ON p.id = oi.product_id;
Going from orders to order_items changes the grain: the result now has one row per order line, not per order.

The grain of a result means "what one row stands for". After step 1 one row is one order; after step 2 it is one order line. Always know the grain, because it decides what SUM and COUNT will mean.

Revenue per customer

Now add WHERE and GROUP BY exactly as in module 4. The joins run first (they are part of FROM), then WHERE filters the joined rows, then GROUP BY makes one row per group.

sql
SELECT c.name, SUM(oi.qty * oi.price) AS revenue
FROM customers AS c
JOIN orders AS o       ON o.customer_id = c.id
JOIN order_items AS oi ON oi.order_id = o.id
WHERE o.status = 'Delivered'
GROUP BY c.id, c.name
ORDER BY revenue DESC;
-- Ravi Kumar | 6497
-- Asha Verma | 4028
Only two customers have delivered orders. Group by c.id as well as c.name, in case two customers share a name.

Revenue per category

sql
SELECT p.category, SUM(oi.qty * oi.price) AS revenue
FROM order_items AS oi
JOIN orders AS o   ON o.id = oi.order_id
JOIN products AS p ON p.id = oi.product_id
WHERE o.status <> 'Cancelled'
GROUP BY p.category
ORDER BY revenue DESC;
-- Fashion     | 5196
-- Electronics | 3998
-- Grocery     | 2750
-- Kitchen     | 1900
You can start from any table on the path. Starting from order_items, the table at the grain you want to add up, is often clearest.
Tip: Use the price stored in order_items (the price paid), not products.price (today's price). If the shop changes a price tomorrow, old orders must keep their old amount.

Does the order of JOINs matter?

For inner joins, no: the database's query planner (the part that decides how to run a query) reorders them for speed, and the result is the same. With LEFT JOINs the order does matter, because "keep every row of the left side" depends on what is on the left. A common trap: customers LEFT JOIN orders, then INNER JOIN order_items. The inner join drops the NULL rows of customers without orders, so Arjun disappears again. Once you start a LEFT JOIN chain, keep the following joins LEFT too.

Common mistakes

  • Skipping a table on the path, such as joining customers straight to order_items. There is no shared key; you must go through orders.
  • Losing track of the grain and counting order lines when you meant orders. Use COUNT(DISTINCT o.id) when the grain is lines.
  • Mixing a LEFT JOIN with a later INNER JOIN and losing the unmatched rows. Make the later joins LEFT JOINs too.
  • Writing all the joins at once and not knowing which one is wrong. Build step by step and check the row count.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Joining three or more tables · 1/6

Joining three or more tables

Real questions often need three or four tables. Joins chain together, one table at a time.

Prefer a video by a teacher? Find free videos on “sql join multiple tables three tables group by tutorial” on YouTube ↗

In real life: A category sales report for a merchandising team

An online store's category managers look at revenue by category every week to decide which products to promote. The report is a join from order lines to orders (for the date and status) and products (for the category), grouped by category. Many teams put such a query behind a dashboard tool, so it runs fresh each morning.

sql
SELECT p.category, COUNT(DISTINCT o.id) AS orders, SUM(oi.qty) AS units
FROM order_items AS oi
JOIN orders AS o   ON o.id = oi.order_id
JOIN products AS p ON p.id = oi.product_id
WHERE o.status <> 'Cancelled'
GROUP BY p.category;
For example, Grocery: 3 orders (101, 105, 108) and 8 units.

Practice (0/4 done)

Task 1 · Code · SQL

How much has each customer spent on Delivered orders? Return the customer name and revenue (the sum of qty * price from order_items), highest revenue first.

Task 2 · Code · SQL

Which product categories has each customer bought (any order status)? Return each customer name and category pair once, sorted by name and then category.

Task 3 · Code · SQL

For every order, return the order id, the customer name and items (the total quantity of all its lines).

Task 4 · Code · SQL

Revenue per category, ignoring Cancelled orders. Return category and revenue (sum of qty * price from order_items), highest revenue first.

Quick quiz

Question 1

1.To get each customer's product categories in SHOP, which tables must the query pass through?

Question 2

2.orders JOIN order_items: what does one row of the result stand for?

Question 3

3.customers LEFT JOIN orders ... JOIN order_items (an inner join). What happens to Arjun, who has no orders?

Question 4

4.Why use order_items.price rather than products.price for revenue?

Question 5

5.Why build a big join one table at a time?

Read more (free)

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

To get each customer's product categories in SHOP, which tables must the query pass through?

Answer: customers, orders, order_items and products

Keys connect customers → orders → order_items → products; you cannot skip a link.

orders JOIN order_items: what does one row of the result stand for?

Answer: One order line

The grain becomes the more detailed table, order_items.

customers LEFT JOIN orders ... JOIN order_items (an inner join). What happens to Arjun, who has no orders?

Answer: He is dropped by the inner join, because his NULL order id matches no order line

A later inner join removes the unmatched rows the LEFT JOIN kept. Use LEFT JOIN for the later joins too.

Why use order_items.price rather than products.price for revenue?

Answer: order_items stores the price actually paid, which does not change when the product price changes later

History must keep the amount charged at the time.

Why build a big join one table at a time?

Answer: So you can check the row count after each join and spot the one that goes wrong

Small steps make mistakes easy to locate.

Customers has 7 rows; 2 of them have no orders. What does an INNER JOIN of customers with orders do with those 2 customers?Related

Answer: Leaves them out

INNER JOIN keeps only rows that have a match on both sides. A LEFT JOIN would keep them with NULLs.

From Joins: combining tables

Which is a good check after building revenue per month?Related

Answer: The months add up to the total revenue with the same WHERE

Groups must add up to the total worked out separately.

From Writing reports with aggregation

After customers LEFT JOIN orders ... GROUP BY customer, which gives 0 for a customer with no orders?Related

Answer: COUNT(o.id)

COUNT(column) skips NULLs; the unmatched row has o.id NULL.

From LEFT, RIGHT and FULL OUTER JOIN

Why is the status filter put in the ON clause of the LEFT JOIN in the cohort query?Related

Answer: So customers with no matching orders are kept instead of being filtered out

A WHERE condition on the right-hand table removes the NULL rows and turns the LEFT JOIN into an inner join.

From Project: e-commerce analytics, and your next steps

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. 2 more topics in “JOINs” before its exam: Self joins and cross joins, Join pitfalls and how to avoid them.
  2. Module 5 exam: JOINs · 14 questions · 25 min, without help. 5 topics of this module still to finish first.
  3. Then the next module, Subqueries, CTEs and set operations. 62 topics left in the course before the final exam.

← Previous: LEFT, RIGHT and FULL OUTER JOIN