Step 1 Understand it
Why you need it. "What did each customer buy?" needs the customer's name (customers), the order (orders), the line items (order_items) and the product names (products). That is four . relationships always need at least three.
Watch it happen
Read the animation as text
- FROM orders (only orders before 22 January, to keep it small): one row per order.
- JOIN customers: each order gains its customer's name. Each order has exactly one customer, so the row count stays the same.
- JOIN order_items: an order with two items becomes two rows. The grain is now one row per item bought.
- JOIN products: each item row gains the product's name. Each JOIN needs its own ON, linking to a table already in the query.
Picture it
flowchart LR C["customers"] ---|"customer_id"| O["orders"] O ---|"order_id"| OI["order_items"] OI ---|"product_id"| P["products"]
The idea
Each JOIN adds one table to the you have so far, so a chain of joins follows the arrows in the diagram. Start from the table whose you want in the result — here, one row per item bought, so order_items — or from the table you must keep in full. Each new JOIN needs its own condition linking it to a table already in the . afterwards sums across all of them, for example revenue per category = SUM(quantity × price).
The shape of it
SELECT ...
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
JOIN order_items oi ON oi.order_id = o.order_id
JOIN products p ON p.product_id = oi.product_id;Words in lower case are the parts you fill in.
How it works, step by step
- orders o: one row per order.
- JOIN customers c: each order gains its customer's . Still one row per order.
- JOIN order_items oi: an order with 2 items becomes 2 rows. The grain is now one row per item.
- JOIN products p: each item row gains the product name and price.
- Order 106 has no items, so the to order_items drops it. Use if it must stay.
Analogy
Following a paper trail. A receipt line says "product 6, quantity 3, order 101". You look up order 101 to find the customer, then customer 1 to find the name, then product 6 to find "Headphones". Each lookup is one join.
Where the analogy breaks
On paper you choose which lookups to do. does every join for every row, and an INNER JOIN anywhere in the chain drops rows that fail to match at that step — like order 106, which has no items.
Worked examples
What each order contained
SELECT o.order_id, c.name AS customer, p.product_name, oi.quantity
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
JOIN order_items oi ON oi.order_id = o.order_id
JOIN products p ON p.product_id = oi.product_id
WHERE o.order_date < '2024-01-22'
ORDER BY o.order_id, p.product_name;6 rows
| order_id | customer | product_name | quantity |
|---|---|---|---|
| 101 | Priya | Headphones | 3 |
| 102 | Priya | Backpack | 1 |
| 102 | Priya | Desk lamp | 2 |
| 103 | Priya | Mug | 5 |
| 103 | Priya | Notebook | 1 |
| 104 | Tom | Headphones | 5 |
Orders 102 and 103 each appear twice — one row per item. The grain changed when order_items joined in.
Revenue per category
SELECT p.category, SUM(oi.quantity * p.price) AS revenue
FROM order_items oi
JOIN products p ON p.product_id = oi.product_id
GROUP BY p.category
ORDER BY revenue DESC;5 rows
| category | revenue |
|---|---|
| Electronics | 440 |
| Home | 150 |
| Bags | 120 |
| Kitchen | 48 |
| Stationery | 35 |
quantity × price is worked out per item row, then summed per category. The five add up to 793, the same as all order amounts.
Customers who bought headphones
SELECT DISTINCT c.name
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
JOIN order_items oi ON oi.order_id = o.order_id
JOIN products p ON p.product_id = oi.product_id
WHERE p.product_name = 'Headphones'
ORDER BY c.name;2 rows
| name |
|---|
| Priya |
| Tom |
Priya bought headphones in two orders, so without her name would appear twice.
Must-know points
- One JOIN … ON per extra table; each ON links the new table to one already joined.
- Follow the keys in the diagram. Many-to-many goes through the link table.
- Know the grain after every join: joining a "many" table multiplies rows.
- One INNER JOIN in a chain can drop rows; switch to LEFT JOIN where rows must survive.
- Use clear (c, o, oi, p) and line the ON conditions up so the chain is easy to read.
Common mistakes
- Joining two tables on the wrong pair of columns (
oi.product_id = o.order_id) — it runs but returns nonsense. - Mixing LEFT and INNER joins so that a later INNER JOIN removes the rows the LEFT JOIN kept.
- Summing an order-level column (o.amount) after joining item rows, which counts it once per item (see Join traps).
Step 2 Go further (free, optional)
Optional. The notes above are written to be enough on their own. These free resources give a second explanation and more practice.
- Watch
-
Read
PostgreSQL docs — Joins Between Tables
official documentation, free
-
Practice
free exercises
Step 3 Practise
Try each one yourself first: out loud, or written and run in the practice box below, which saves every query you run. Then open the answer and compare.
-
ExplainSay what it does and when to use it, in plain words, with no notes.
Explain what happens to the number of rows after each join in the first example.
Show answer
Count the rows after each step:
1. orders: 10 rows.
2. JOIN customers: still 10. Each order has exactly one customer, so nothing is added or lost.
3. JOIN order_items: 14. Each order is repeated once per item line (order 102 has two lines, so it now appears twice), and order 106, which has no items, is dropped.
4. JOIN products: still 14. Each item line has exactly one product.
5. order_date < '2024-01-22': 6 rows, the item lines of orders 101 to 104.The rule: joining to the "one" side keeps the count; joining to the "many" side multiplies it; an INNER JOIN with no match removes rows.
sqlSELECT (SELECT COUNT(*) FROM orders) AS orders_only, (SELECT COUNT(*) FROM orders o JOIN order_items oi ON oi.order_id = o.order_id) AS with_items, (SELECT COUNT(*) FROM orders o JOIN order_items oi ON oi.order_id = o.order_id WHERE o.order_date < '2024-01-22') AS after_where;1 row
orders_only with_items after_where 10 14 6 -
Writefrom a blank page, then run it
Write a query for the total quantity of each product bought by Priya, with product names, sorted by quantity.
Show answer
Walk the keys from customers to orders to order_items to products, keep Priya, then group by product and add up the quantities. Mug, Notebook and Pen tie on 5, so the name breaks the tie.
sqlSELECT p.product_name, SUM(oi.quantity) AS total_quantity FROM customers c JOIN orders o ON o.customer_id = c.customer_id JOIN order_items oi ON oi.order_id = o.order_id JOIN products p ON p.product_id = oi.product_id WHERE c.name = 'Priya' GROUP BY p.product_id, p.product_name ORDER BY total_quantity DESC, p.product_name;6 rows
product_name total_quantity Headphones 6 Mug 5 Notebook 5 Pen 5 Backpack 2 Desk lamp 2 -
Predictsay it before you run it
How many rows do you get from joining orders to order_items (all orders, INNER JOIN)? Which order is missing?
Show answer
14 rows: one per item line, not one per order. They cover 9 of the 10 orders. Order 106 is missing, because it has no items, so the INNER JOIN finds nothing to pair it with.
sqlSELECT COUNT(*) AS item_rows, COUNT(DISTINCT o.order_id) AS orders FROM orders o JOIN order_items oi ON oi.order_id = o.order_id;1 row
item_rows orders 14 9 -
Edge casesSay what happens with NULLs, ties, duplicates or no rows at all, and fix it.
Rewrite the first example (without the date filter) so order 106, which has no items, still appears.
Show answer
Make the join to order_items a LEFT JOIN, and the join after it (products) a LEFT JOIN too. If products stayed an INNER JOIN, order 106's product_id would match no product, and the row would be dropped again. Once a chain goes LEFT, every join after it must stay LEFT.
The full query returns 15 rows (14 item lines plus 106). The WHERE below only keeps the result short enough to read; delete it to see all 15.
sqlSELECT o.order_id, c.name AS customer, p.product_name, oi.quantity FROM orders o JOIN customers c ON c.customer_id = o.customer_id LEFT JOIN order_items oi ON oi.order_id = o.order_id LEFT JOIN products p ON p.product_id = oi.product_id WHERE o.order_date >= '2024-02-01' ORDER BY o.order_id, p.product_name;9 rows
order_id customer product_name quantity 105 Priya Backpack 1 105 Priya Headphones 3 106 Lena NULL NULL 107 Lena Backpack 2 108 Tom Desk lamp 3 109 Priya Notebook 4 109 Priya Pen 5 110 Zoe Desk lamp 1 110 Zoe Mug 1
Practice box: write and run your answers here
Your queries in this box
Step 4 Interview-ready, then prove it
Your interview answer
About 20 to 40 seconds, said out loud.
For multi-table queries I follow the one hop at a time, adding a JOIN with its own ON condition per table, and I keep track of the grain after each join — joining a child table like order_items turns one row per order into one row per item. For many-to-many I go through the junction table, and I use LEFT JOINs wherever rows without a match must survive.
Follow-up questions
Does the order of INNER JOINs matter?
Not for the result — inner joins can be reordered freely, and the picks its own order. With LEFT JOINs, order does matter.
How do you get from customers to products?
customers → orders (customer_id) → order_items (order_id) → products (product_id).
Tick each check when you can do it without notes
A topic is finished when you pass all four checks, not when you have read it. Be strict: if you had to look at the notes, it is not ticked yet.
- Explain Say what it does and when to use it, in plain words, with no notes.
- Write Write the query from a blank page, then run it to check.
- Predict Read a query and say which rows it returns before you run it.
- Edge cases Say what happens with NULLs, ties, duplicates or no rows at all, and fix it.
0 of 4 checks ticked.
Saved in this browser. Back it up or move it to another device.