Joining Multiple Tables
Real questions often span three, four or more tables: which customers bought which products, how much each category earned per country. Joins chain naturally — each JOIN adds one more table to the result. This lesson also covers the most common multi-join mistake: rows multiplying unexpectedly.
Chaining joins
SELECT c.name AS customer, o.id AS order_id, p.name AS product, oi.quantity 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 JOIN products AS p ON p.id = oi.product_id WHERE c.name = 'Ana Pereira' ORDER BY o.id;
| customer | order_id | product | quantity |
|---|---|---|---|
| Ana Pereira | 1 | The Patient Gardener | 1 |
| Ana Pereira | 1 | Ceramic Mug | 2 |
| Ana Pereira | 3 | Learning SQL Step by Step | 1 |
| Ana Pereira | 3 | USB-C Charger | 2 |
| Ana Pereira | 11 | Desk Lamp | 1 |
| Ana Pereira | 11 | Ceramic Mug | 1 |
Follow the keys: customer → orders → order items → products. Each JOIN connects the new table to one already in the query.
Summaries across many tables
SELECT c.country, p.category, round(SUM(oi.quantity * oi.unit_price), 2) 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 JOIN products AS p ON p.id = oi.product_id WHERE o.status <> 'cancelled' GROUP BY c.country, p.category HAVING revenue > 80 ORDER BY revenue DESC;
| country | category | revenue |
|---|---|---|
| UK | Electronics | 168.98 |
| Portugal | Books | 154.9 |
| France | Home | 133 |
| Czechia | Electronics | 119.98 |
| UK | Home | 98 |
Mixing inner and outer joins
SELECT c.name, COUNT(DISTINCT o.id) AS orders, COALESCE(SUM(oi.quantity), 0) AS units FROM customers AS c LEFT JOIN orders AS o ON o.customer_id = c.id LEFT JOIN order_items AS oi ON oi.order_id = o.id GROUP BY c.id, c.name ORDER BY units DESC, c.name;
| name | orders | units |
|---|---|---|
| Ana Pereira | 3 | 8 |
| Chloé Martin | 2 | 6 |
| Ben Carter | 2 | 3 |
| David Müller | 2 | 3 |
| Elena García | 1 | 3 |
| Farah Haddad | 1 | 3 |
| George Brown | 2 | 3 |
| Isaac Levi | 1 | 3 |
| Hana Novak | 1 | 2 |
| Julia Santos | 0 | 0 |
Once a LEFT JOIN introduces possible NULLs, the following joins usually need to be LEFT JOINs too. An inner join on oi here would remove Julia again, because her NULL order can't match any item.
The row multiplication trap
Suppose you want each customer's number of orders and total units:
SELECT c.name, COUNT(o.id) AS orders_wrong 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 c.id = 1 GROUP BY c.name;
| name | orders_wrong |
|---|---|
| Ana Pereira | 6 |
Ana has three orders, not six. After joining order_items, each order appears once per item, so COUNT(o.id) counts item rows. Fixes include COUNT(DISTINCT o.id), or aggregating each table separately before joining:
WITH order_counts AS ( SELECT customer_id, COUNT(*) AS orders FROM orders GROUP BY customer_id ), unit_counts AS ( SELECT o.customer_id, SUM(oi.quantity) AS units FROM orders AS o JOIN order_items AS oi ON oi.order_id = o.id GROUP BY o.customer_id ) SELECT c.name, oc.orders, uc.units FROM customers AS c JOIN order_counts AS oc ON oc.customer_id = c.id JOIN unit_counts AS uc ON uc.customer_id = c.id WHERE c.id = 1;
| name | orders | units |
|---|---|---|
| Ana Pereira | 3 | 8 |
GROUP BY and look at the rows. If a value you're summing appears more than once, your totals will be inflated.Join order and readability
For inner joins the order you write tables in doesn't change the result — the database's query planner picks the most efficient order anyway. Write joins in the order that tells the story: start from the main subject of the question and follow the relationships outward.
Try it yourself
Which employees work in a department located in the same city as at least one customer?