Step 1 Solve it yourself first
Tables you will use
What it really tests
A on a , and the trick for listing each pair once — the basis of "customers who bought this also bought…".
The pattern: Pairs within a group (self join with a < b)
"Bought together", "pairs", "in the same order / session / team", "co-occurrence".
Picture it
flowchart LR O["order 102<br/>Desk lamp, Backpack"] --> P["self join on order_id<br/>Backpack–Desk lamp<br/>Desk lamp–Backpack<br/>Backpack–Backpack …"] P -->|"keep name a less than name b"| K["Backpack–Desk lamp<br/>once"]
The approach, step by step
- Join order_items to itself on the same order_id: every product paired with every other product in that order.
- Keep only pairs where the first product's name is less than the second's — this removes self-pairs and mirrored duplicates (A–B and B–A).
- Join products twice to get both names.
- Group by the pair and count the orders.
Your turn: write it before you look
Your queries in this box
Show the solution
Stuck for more than ten minutes? Open it, read it, close it, and write it again from memory.
Solution: self join with a < b
SELECT p1.product_name AS product_a, p2.product_name AS product_b,
COUNT(*) AS orders_together
FROM order_items a
JOIN order_items b ON b.order_id = a.order_id
JOIN products p1 ON p1.product_id = a.product_id
JOIN products p2 ON p2.product_id = b.product_id
WHERE p1.product_name < p2.product_name
GROUP BY p1.product_name, p2.product_name
ORDER BY orders_together DESC, product_a, product_b;5 rows
| product_a | product_b | orders_together |
|---|---|---|
| Backpack | Desk lamp | 1 |
| Backpack | Headphones | 1 |
| Desk lamp | Mug | 1 |
| Mug | Notebook | 1 |
| Notebook | Pen | 1 |
Each pair appears in one order in this small data set. On real data you would sort by orders_together and take the top pairs.
Edge cases to say out loud
- The same product twice in one order: order_items has one per product per order, so it can't pair with itself here — but say how you would handle it.
- Big orders make many pairs (n items → n × (n − 1) / 2 pairs), so this can get expensive on real data.
- Pairs vs. lift: "bought together most" favours popular products; analysts often by each product's own popularity.
Common mistakes
- Using
<>instead of<, which lists every pair twice. - Joining on product_id instead of order_id, which pairs a product with itself across orders.
- Counting rows when an order could contain a pair more than once — use
COUNT(DISTINCT a.order_id)if so.
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.
-
Practice
DataLemur — frequently bought together questions
real interview questions, many free
-
Practice
PostgreSQL Exercises — Joins and Subqueries
pgexercises.com, free
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 the self join produces before the , for order 102.
Show answer
Order 102 has two item lines: product 4 (desk lamp) and product 5 (backpack). Joining order_items to itself on order_id pairs every line of the order with every line of the same order, 2 × 2 = 4 rows: (lamp, lamp), (lamp, backpack), (backpack, lamp), (backpack, backpack).
Two of those are a product paired with itself, and the other two are the same pair in both orders.
WHERE p1.product_name < p2.product_nameremoves both problems at once: a name is never less than itself, and of (lamp, backpack) and (backpack, lamp) only one has the names in alphabetical order. One row is left: (Backpack, Desk lamp).sqlSELECT a.order_id, a.product_id AS a_product, b.product_id AS b_product FROM order_items a JOIN order_items b ON b.order_id = a.order_id WHERE a.order_id = 102 ORDER BY a.product_id, b.product_id;4 rows
order_id a_product b_product 102 4 4 102 4 5 102 5 4 102 5 5 -
Writefrom a blank page, then run it
For each product, list how many different products it has ever been bought with.
Show answer
Pair each product's item lines with the other lines of the same order (
b.product_id <> a.product_id), and count distinct partners. keep the products with no partners: the headphones are often bought alone, and the phone case has never been bought at all.sqlSELECT p.product_name, COUNT(DISTINCT b.product_id) AS bought_with FROM products p LEFT JOIN order_items a ON a.product_id = p.product_id LEFT JOIN order_items b ON b.order_id = a.order_id AND b.product_id <> a.product_id GROUP BY p.product_id, p.product_name ORDER BY bought_with DESC, p.product_name;7 rows
product_name bought_with Backpack 2 Desk lamp 2 Mug 2 Notebook 2 Headphones 1 Pen 1 Phone case 0 -
Predictsay it before you run it
How many rows does the self join
order_items a JOIN order_items b ON b.order_id = a.order_idproduce before any WHERE?Show answer
24. Each order contributes (its number of item lines)², because every line is paired with every line of the same order, itself included. Orders 101, 104, 107 and 108 have 1 line each (1 row each); orders 102, 103, 105, 109 and 110 have 2 lines each (4 rows each). 4 × 1 + 5 × 4 = 24.
sqlSELECT COUNT(*) AS pairs_before_where FROM order_items a JOIN order_items b ON b.order_id = a.order_id;1 row
pairs_before_where 24 -
Edge casesSay what happens with NULLs, ties, duplicates or no rows at all, and fix it.
If an order could contain the same product on two rows, how would you stop it pairing with itself and ?
Show answer
Two separate problems, two fixes:
• Self-pairs: compare product ids, not rows.
a.product_id < b.product_idcan never pair a product with itself, even if it appears on two rows of the order (two lines with the same product have equal ids, so<is false).
• Double counting: the same pair would come out several times for one order (2 lines × 1 line = 2 rows). Count orders, not rows:COUNT(DISTINCT a.order_id). Or remove the duplicates first, in a :SELECT DISTINCT order_id, product_id FROM order_items, then self-join that.In this it can't happen, because the (order_id, product_id) allows one row per product per order. But many real order have no such key.
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.
I self-join the order items table on order_id so every product is paired with every other product in the same order, filter with product a less than product b to drop self-pairs and mirrored duplicates, product names, and count orders per pair. I'd note that the number of pairs grows quadratically with basket size, so on big data I might limit to recent orders or popular products.
Follow-up questions
Why < and not <>?
<> removes self-pairs but keeps both A–B and B–A. < keeps exactly one ordering of each pair.
How would you find triples bought together?
Join order_items three times on order_id with a < b < c, then group by all three.
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.