Joining three or more tables, and self joins
Real questions cross several tables. "What did Asha buy?" needs the customer, her orders, the lines of those orders and the products on those lines. That is four tables. The good news is that a join of four tables is just three joins of two, one after another.
The map
Before joining, know how the tables link.
orders.customer_idpoints tocustomers.idorder_items.order_idpoints toorders.idorder_items.product_idpoints toproducts.idemployees.manager_idpoints toemployees.id
order_items sits in the middle, tying orders to products. An order can hold many products, and a product can be in many orders. A table like this, whose job is to link two others, is called a junction table.
Adding one join at a time
Start with two tables and check the result. Then add the third.
SELECT o.id AS order_id, c.name AS customer, p.name AS product, oi.quantity
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON oi.product_id = p.id
WHERE o.id IN (1, 2)
ORDER BY o.id, p.name;Result
| order_id | customer | product | quantity |
|---|---|---|---|
| 1 | Asha Verma | Basmati Rice 5 kg | 2 |
| 1 | Asha Verma | Notebook 200 pages | 4 |
| 1 | Asha Verma | Toor Dal 1 kg | 1 |
| 2 | Ravi Kumar | Extension Board | 1 |
| 2 | Ravi Kumar | LED Bulb 9 W | 4 |
5 rows
Each JOIN adds one table and has its own ON saying how that table attaches to what is already there. Build big queries this way: run after each added join and look at the row count. If the count jumps unexpectedly, the last join is wrong.
What did one customer buy?
SELECT o.order_date, p.name AS product, oi.quantity
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON oi.product_id = p.id
WHERE c.name = 'Asha Verma'
ORDER BY o.order_date, p.name;Result
| order_date | product | quantity |
|---|---|---|
| 2026-01-05 | Basmati Rice 5 kg | 2 |
| 2026-01-05 | Notebook 200 pages | 4 |
| 2026-01-05 | Toor Dal 1 kg | 1 |
| 2026-02-02 | Sunflower Oil 1 L | 2 |
| 2026-02-02 | Tea 500 g | 1 |
| 2026-05-06 | Earphones | 1 |
| 2026-05-06 | Power Bank | 1 |
| 2026-10-04 | Ball Pen pack of 10 | 1 |
| 2026-10-04 | Notebook 200 pages | 6 |
| 2026-10-04 | School Bag | 2 |
10 rows
The value of each order
An order's value is the sum of quantity times price over its lines. Join, then group by order.
SELECT o.id AS order_id, o.order_date, c.name AS customer,
SUM(oi.quantity * p.price) AS total
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON oi.product_id = p.id
GROUP BY o.id, o.order_date, c.name
ORDER BY total DESC
LIMIT 5;Result
| order_id | order_date | customer | total |
|---|---|---|---|
| 20 | 2026-10-04 | Asha Verma | 1950 |
| 17 | 2026-08-11 | Priya Nair | 1900 |
| 12 | 2026-05-06 | Asha Verma | 1798 |
| 13 | 2026-05-19 | Sunita Rao | 1798 |
| 1 | 2026-01-05 | Asha Verma | 1700 |
5 rows
Money spent by each customer
Group by the customer instead of the order, and leave out cancelled orders.
SELECT c.name, COUNT(DISTINCT o.id) AS orders,
SUM(oi.quantity * p.price) AS spent
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON oi.product_id = p.id
WHERE o.status <> 'cancelled'
GROUP BY c.id, c.name
ORDER BY spent DESC;Result
| name | orders | spent |
|---|---|---|
| Asha Verma | 4 | 6018 |
| Priya Nair | 2 | 3350 |
| Ravi Kumar | 3 | 2949 |
| Sunita Rao | 2 | 2788 |
| Joy Thomas | 1 | 1458 |
| Imran Ali | 2 | 1409 |
| Kabir Khan | 2 | 1320 |
| Vikram Das | 1 | 1115 |
| Neha Joshi | 1 | 770 |
9 rows
Look closely at COUNT(DISTINCT o.id). After joining to the items, an order with three lines appears as three rows. A plain COUNT(*) would count lines, not orders. This is the most common mistake in multi-table reports: joining to a table with many rows per parent multiplies the parent's rows. Whenever you count or add something from a "one" side after joining to a "many" side, stop and check.
Sales by category
SELECT p.category,
SUM(oi.quantity) AS units,
SUM(oi.quantity * p.price) AS sales
FROM order_items oi
JOIN products p ON oi.product_id = p.id
JOIN orders o ON oi.order_id = o.id
WHERE o.status = 'delivered'
GROUP BY p.category
ORDER BY sales DESC;Result
| category | units | sales |
|---|---|---|
| Electronics | 18 | 4814 |
| Grocery | 13 | 4355 |
| Home | 4 | 2800 |
| Stationery | 26 | 2520 |
4 rows
Mixing inner and left joins
To keep the customers with no orders in a spending report, every join from customers onwards must be a left join. One inner join further down the chain would drop them again.
SELECT c.name, COALESCE(SUM(oi.quantity * p.price), 0) AS spent
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status <> 'cancelled'
LEFT JOIN order_items oi ON oi.order_id = o.id
LEFT JOIN products p ON oi.product_id = p.id
GROUP BY c.id, c.name
ORDER BY spent DESC, c.name;Result
| name | spent |
|---|---|
| Asha Verma | 6018 |
| Priya Nair | 3350 |
| Ravi Kumar | 2949 |
| Sunita Rao | 2788 |
| Joy Thomas | 1458 |
| Imran Ali | 1409 |
| Kabir Khan | 1320 |
| Vikram Das | 1115 |
| Neha Joshi | 770 |
| Arjun Mehta | 0 |
| Meena Gill | 0 |
| Tara Singh | 0 |
12 rows
The self join
A table can be joined to itself. In employees, each row has a manager_id that points to another row of the same table. To show each person beside their manager's name, use the table twice, under two different aliases, as if it were two tables.
SELECT e.name AS employee, m.name AS manager
FROM employees e
JOIN employees m ON e.manager_id = m.id
ORDER BY m.name, e.name;Result
| employee | manager |
|---|---|
| Farah Sheikh | Anita Kapoor |
| Harpreet Kaur | Anita Kapoor |
| Rohit Bansal | Anita Kapoor |
| Gopal Reddy | Farah Sheikh |
| Nikhil Jain | Harpreet Kaur |
| Deepa Iyer | Rohit Bansal |
| Manoj Yadav | Rohit Bansal |
7 rows
Think of e as "the employee" copy and m as "the manager" copy. The aliases are not optional here. Without them the database could not tell the two uses apart.
The head of the shop is missing from that list, because she has no manager to match. A left join keeps her.
SELECT e.name AS employee, COALESCE(m.name, 'no manager') AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id
ORDER BY e.id;Result
| employee | manager |
|---|---|
| Anita Kapoor | no manager |
| Rohit Bansal | Anita Kapoor |
| Deepa Iyer | Rohit Bansal |
| Manoj Yadav | Rohit Bansal |
| Farah Sheikh | Anita Kapoor |
| Gopal Reddy | Farah Sheikh |
| Harpreet Kaur | Anita Kapoor |
| Nikhil Jain | Harpreet Kaur |
8 rows
How many people report to each manager?
SELECT m.name AS manager, COUNT(*) AS team_size
FROM employees e
JOIN employees m ON e.manager_id = m.id
GROUP BY m.id, m.name
ORDER BY team_size DESC, m.name;Result
| manager | team_size |
|---|---|
| Anita Kapoor | 3 |
| Rohit Bansal | 2 |
| Farah Sheikh | 1 |
| Harpreet Kaur | 1 |
4 rows
Who earns more than their own manager? A self join can compare the two rows.
SELECT e.name, e.salary, m.name AS manager, m.salary AS manager_salary
FROM employees e
JOIN employees m ON e.manager_id = m.id
WHERE e.salary > m.salary;Result
| name | salary | manager | manager_salary |
|---|
0 rows
Nobody does in our shop, so the result is empty. This question is an interview favourite.
Pairs within one table
A self join can also pair rows that share something. These are pairs of customers in the same city. The condition a.id < b.id stops each pair from appearing twice and stops a customer from being paired with themselves.
SELECT a.name AS first, b.name AS second, a.city
FROM customers a
JOIN customers b ON a.city = b.city AND a.id < b.id
ORDER BY a.city;Result
| first | second | city |
|---|---|---|
| Ravi Kumar | Imran Ali | Delhi |
| Asha Verma | Tara Singh | Ludhiana |
| Kabir Khan | Joy Thomas | Mumbai |
3 rows
A method for any join query
- Write down which tables hold the columns you need to show and to filter.
- Find the path of keys that connects them. Include any table in between, even if you show nothing from it.
- Start from the table whose rows the result is "about".
- Add one join at a time, checking the row count.
- Only then add
WHERE,GROUP BYand the rest.
Try it yourself
Exercise 1. List the different product names that Ravi Kumar has ordered, in alphabetical order.
Show one answer
SELECT DISTINCT p.name FROM customers c JOIN orders o ON o.customer_id = c.id JOIN order_items oi ON oi.order_id = o.id JOIN products p ON oi.product_id = p.id WHERE c.name = 'Ravi Kumar' ORDER BY p.name;| name |
|---|
| Ball Pen pack of 10 |
| Basmati Rice 5 kg |
| Extension Board |
| Geometry Box |
| LED Bulb 9 W |
| Notebook 200 pages |
| Tea 500 g |
7 rows
Exercise 2. Show the total value (quantity times price) of order number 20 as one number.
Show one answer
SELECT SUM(oi.quantity * p.price) FROM order_items oi JOIN products p ON oi.product_id = p.id WHERE oi.order_id = 20;| SUM(oi.quantity * p.price) |
|---|
| 1950 |
1 row
Exercise 3. Show the names of the employees whose manager is Rohit Bansal.
Show one answer
SELECT e.name FROM employees e JOIN employees m ON e.manager_id = m.id WHERE m.name = 'Rohit Bansal';| name |
|---|
| Deepa Iyer |
| Manoj Yadav |
2 rows
Examples on this page were run with SQLite 3.50.4 on 12 November 2026. The results shown are the real results.
This is an independent learning site. It is not connected with the makers of any database product. MySQL, PostgreSQL, SQL Server, Oracle and SQLite are named only so that you know where a piece of SQL works. The shop, its customers and its staff in the practice database are made up.