The shop stores each fact once: a customer's city lives only in customers, a product's price only in products. Orders refer to them by id. A join puts the pieces back together for a report by matching keys. The ON clause says which columns must be equal:
8. Joins and grain
Step 1 of 4 · Lesson
Lesson 8 of 21
MySQL JOIN explained: INNER, LEFT, self join and double counting
Combine tables with INNER JOIN and LEFT JOIN, find rows with no match, join a table to itself, and avoid the double counting that row expansion causes.
In short
What is the difference between INNER JOIN and LEFT JOIN?
INNER JOIN returns only rows that have a match in both tables. LEFT JOIN returns every row from the left table and fills the right table's columns with NULL where there is no match. Use LEFT JOIN … WHERE right.id IS NULL to find rows with no match, such as customers who never ordered.
By the end of this lesson you can
- Join two, three and four tables on their keys with table aliases
- Choose between INNER JOIN and LEFT JOIN, and find unmatched rows
- Explain why joins multiply rows, and count correctly with COUNT(DISTINCT …)
- Join a table to itself, and know what RIGHT JOIN and CROSS JOIN do
Why joins exist
SELECT o.order_id, o.order_date, c.customer_name, c.city
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
ORDER BY o.order_id;| order_id | order_date | customer_name | city |
|---|---|---|---|
| 101 | 2026-01-05 | Asha | Pune |
| 102 | 2026-01-08 | Bilal | Mumbai |
| 103 | 2026-02-03 | Asha | Pune |
| 104 | 2026-02-10 | Charu | Pune |
| 105 | 2026-02-15 | Dev | Delhi |
| 106 | 2026-03-02 | Bilal | Mumbai |
| 107 | 2026-03-04 | Esha | Mumbai |
| 108 | 2026-03-11 | Dev | Delhi |
orders o gives the table a short alias. Once you alias, prefix every column (o.order_id, c.city) so a reader — and MySQL — knows where it comes from. JOIN on its own means INNER JOIN.
INNER JOIN keeps only matches
An inner join returns a row only when the ON condition finds a partner on both sides. Farah (customer 6) has no orders, so she does not appear above. Usually that is what you want — but not when the question is “list all customers”.
INNER JOIN — only customers with orders
LEFT JOIN — every customer; NULL where no order
LEFT JOIN keeps every row on the left
A LEFT JOIN keeps every row of the left (first) table. Where no partner exists, the right table's columns are NULL:
SELECT c.customer_id, c.customer_name, o.order_id, o.status
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
ORDER BY c.customer_id, o.order_id;| customer_id | customer_name | order_id | status |
|---|---|---|---|
| 1 | Asha | 101 | completed |
| 1 | Asha | 103 | completed |
| 2 | Bilal | 102 | completed |
| 2 | Bilal | 106 | completed |
| 3 | Charu | 104 | cancelled |
| 4 | Dev | 105 | completed |
| 4 | Dev | 108 | completed |
| 5 | Esha | 107 | pending |
| 6 | Farah | NULL | NULL |
Farah now appears with NULL order columns. Customers with two orders appear twice — once per match.
Find rows with no match (anti-join)
Add WHERE right_table.key IS NULL to a LEFT JOIN and only the unmatched rows remain. This answers who never ordered?, which products never sold?, which orders have no items?:
SELECT c.customer_id, c.customer_name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;| customer_id | customer_name |
|---|---|
| 6 | Farah |
Joins multiply rows — mind the grain
Joining orders (one row per order) to order_items (one row per item) gives one row per item. Order 101 had one row; after the join it has two. This is called row expansion, and it is the number-one source of wrong totals in SQL.
SELECT COUNT(*) AS joined_rows,
COUNT(DISTINCT o.order_id) AS completed_orders,
SUM(i.quantity * i.unit_price) AS revenue
FROM orders o
JOIN order_items i ON i.order_id = o.order_id
WHERE o.status = 'completed';| joined_rows | completed_orders | revenue |
|---|---|---|
| 12 | 6 | 6150.00 |
12 joined rows, but only 6 orders. COUNT(*) after the join counts items. Use COUNT(DISTINCT o.order_id) for orders. Revenue (6150.00) is correct because quantity × unit_price lives at the item grain.
Three and four tables
Chain joins one at a time. Each JOIN … ON adds one table using a key it shares with a table already in the query:
SELECT o.order_id, c.customer_name, p.product_name, i.quantity, i.unit_price,
i.quantity * i.unit_price AS line_total
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
JOIN order_items i ON i.order_id = o.order_id
JOIN products p ON p.product_id = i.product_id
WHERE o.order_id IN (101, 102)
ORDER BY o.order_id, p.product_id;| order_id | customer_name | product_name | quantity | unit_price | line_total |
|---|---|---|---|---|---|
| 101 | Asha | Notebook | 2 | 100.00 | 200.00 |
| 101 | Asha | Pen | 5 | 20.00 | 100.00 |
| 102 | Bilal | Desk lamp | 1 | 800.00 | 800.00 |
| 102 | Bilal | Mug | 2 | 250.00 | 500.00 |
Self join: a table joined to itself
Give the same table two aliases and it can be compared with itself — for example, pairs of customers who live in the same city:
SELECT a.customer_name AS customer, b.customer_name AS neighbour, a.city
FROM customers a
JOIN customers b
ON b.city = a.city
AND b.customer_id > a.customer_id -- each pair once, no self-pairs
ORDER BY a.city, a.customer_id;| customer | neighbour | city |
|---|---|---|
| Bilal | Esha | Mumbai |
| Asha | Charu | Pune |
Self joins also model hierarchies (an employees table where manager_id points to another employee) and before/after comparisons.
RIGHT JOIN and CROSS JOIN (briefly)
- RIGHT JOIN is a LEFT JOIN written the other way round: it keeps every row of the right table. Most teams always write LEFT JOIN and put the table they want to keep first, so queries read the same way.
- CROSS JOIN pairs every row of one table with every row of the other (6 customers × 6 products = 36 rows). It is useful for building grids — every city × every category — and dangerous by accident: a JOIN with a missing or wrong ON condition behaves like one.
- MySQL has no FULL OUTER JOIN; combine a LEFT JOIN and a RIGHT JOIN with UNION (lesson 10).
SELECT COUNT(*) AS pairs
FROM customers
CROSS JOIN products;| pairs |
|---|
| 36 |
Common mistakes
Wrong
COUNT(*) after joining orders to order_itemsFix
COUNT(DISTINCT o.order_id)Why: Counts item rows (12), not orders (6).
Wrong
LEFT JOIN orders o … WHERE o.status = 'completed'Fix
Move the condition into ON: … ON o.customer_id = c.customer_id AND o.status = 'completed'
Why: Rows with no order have NULL status and are removed, so the LEFT JOIN acts like an INNER JOIN.
Wrong
Joining on names (ON p.product_name = …)
Fix
Join on primary / foreign keys.
Why: Names change and can repeat; ids are the keys.
Wrong
SELECT order_id FROM orders o JOIN order_items i ON …Fix
Prefix with the alias: o.order_id.
Why: order_id exists in both tables, so MySQL raises error 1052 (ambiguous).
Check yourself
- Why does Farah appear with LEFT JOIN but not with INNER JOIN?
- Orders joined to items gives 12 rows for 6 completed orders. Explain why, and how to count orders.
- Where should a filter on the right-hand table go in a LEFT JOIN, and why?
Answer out loud or in your notebook. If one is hard, reread that section before practising.
Frequently asked questions
Is JOIN the same as INNER JOIN in MySQL?
Yes. JOIN, INNER JOIN and CROSS JOIN with an ON clause all produce an inner join in MySQL.
How do I find records that do not exist in another table?
LEFT JOIN the other table and keep rows where its key IS NULL, or use WHERE NOT EXISTS (SELECT 1 FROM other WHERE other.key = this.key). Both are shown in this course.
Does MySQL support FULL OUTER JOIN?
No. Emulate it with a LEFT JOIN UNION a RIGHT JOIN of the same tables.
Quick quiz — 4 questions
Quick Check: Joins and grain
Meet Learning Shop
One small fictional shop powers the whole course: 6 customers, 6 products, 8 orders and 14 order lines, small enough to check every answer by eye. The practice editor on this page already contains it. To practise in MySQL Workbench, import it once.
customers.customer_id → orders.customer_id · orders.order_id → order_items.order_id · products.product_id → order_items.product_id
Import in MySQL Workbench
- Connect to your local MySQL server (8.0.16 or newer; 8.4 recommended).
- Choose File → Open SQL Script and open the downloaded file.
- Click the lightning bolt to execute the whole script. The last result shows 6, 6, 8 and 14 rows.
- Refresh the Schemas panel, then select the database:
USE portfolio_retail_lab;
SHOW TABLES;Command line instead: mysql -u root -p < mysql-retail-lab.sql
In the practice editor, the Reset database button does the same as the reset script — instantly and only in your browser.
Let’s do one together
Calculate completed revenue correctly, and see how a raw count goes wrong.
- 1Run the query: total completed revenue is 6150.00.
- 2Add COUNT(*) AS rows_after_join to the SELECT — it says 12.
- 3Add COUNT(DISTINCT o.order_id) AS orders — it says 6. That is the real order count.
- 4Remove the WHERE line and compare: cancelled and pending items now inflate revenue.
SELECT SUM(i.quantity * i.unit_price) AS revenue
FROM orders o
JOIN order_items i ON i.order_id = o.order_id
WHERE o.status = 'completed';What you should see
One row: revenue = 6150.00 (completed orders only).
| revenue |
|---|
| 6150.00 |
Practice problems
19 problems from Easy to Professional · 0 solved · J / K to move
Attach customer names to every order.
Target: 8 rows, not 14. Order 101 belongs to Asha.
Database already selected: Learning Shop (small)(portfolio_retail_lab)— write the query directly, no USE needed.
I’m stuck — show a hint
Join orders.customer_id to customers.customer_id.
Reference answer
SELECT o.order_id, c.customer_name FROM orders o JOIN customers c ON c.customer_id=o.customer_id ORDER BY o.order_id;| order_id | customer_name |
|---|---|
| 101 | Asha |
| 102 | Bilal |
| 103 | Asha |
| 104 | Charu |
| 105 | Dev |
| 106 | Bilal |
| 107 | Esha |
| 108 | Dev |
More than one query can be correct. If your result matches the target, your query is right.
Find customers who have never placed any order.
Target: Farah only. Esha has a pending order and must not be included.
I’m stuck — show a hint
LEFT JOIN then test the right-side primary key for NULL.
Reference answer
SELECT c.customer_name FROM customers c LEFT JOIN orders o ON o.customer_id=c.customer_id WHERE o.order_id IS NULL;| customer_name |
|---|
| Farah |
More than one query can be correct. If your result matches the target, your query is right.
Calculate each completed order total.
Target: 101=300, 102=1300, 103=1400, 105=550, 106=1300, 108=1300; sum=6150.
I’m stuck — show a hint
Group quantity * unit_price by order id after filtering status.
Reference answer
SELECT o.order_id, SUM(i.quantity*i.unit_price) AS total FROM orders o JOIN order_items i ON i.order_id=o.order_id WHERE o.status='completed' GROUP BY o.order_id ORDER BY o.order_id;| order_id | total |
|---|---|
| 101 | 300.00 |
| 102 | 1300.00 |
| 103 | 1400.00 |
| 105 | 550.00 |
| 106 | 1300.00 |
| 108 | 1300.00 |
More than one query can be correct. If your result matches the target, your query is right.
Deliver completed order count and revenue in one audited result.
Target: 6 orders and 6150 revenue. Explain why a raw COUNT(*) returns 12 item rows.
I’m stuck — show a hint
Distinct counts protect the order grain, but never use SUM(DISTINCT amount) to fix a bad join.
Reference answer
SELECT COUNT(DISTINCT o.order_id) AS orders, SUM(i.quantity*i.unit_price) AS revenue FROM orders o JOIN order_items i ON i.order_id=o.order_id WHERE o.status='completed';| orders | revenue |
|---|---|
| 6 | 6150.00 |
More than one query can be correct. If your result matches the target, your query is right.
Show each order item with the product name.
Target: 14 rows; first (101, Notebook, 2), last (108, Mug, 2)
I’m stuck — show a hint
Join order_items to products on product_id.
Reference answer
SELECT i.order_id, p.product_name, i.quantity FROM order_items i JOIN products p ON p.product_id = i.product_id ORDER BY i.order_id, p.product_id;| order_id | product_name | quantity |
|---|---|---|
| 101 | Notebook | 2 |
| 101 | Pen | 5 |
| 102 | Desk lamp | 1 |
| 102 | Mug | 2 |
| 103 | Pen | 10 |
| 103 | Keyboard | 1 |
| 104 | Desk lamp | 1 |
| 105 | Notebook | 3 |
| 105 | Mug | 1 |
| 106 | Notebook | 1 |
| 106 | Keyboard | 1 |
| 107 | Planner | 2 |
| 108 | Desk lamp | 1 |
| 108 | Mug | 2 |
More than one query can be correct. If your result matches the target, your query is right.
Show every order with customer name and city.
Target: 8 rows; first (101, Asha, Pune), last (108, Dev, Delhi)
I’m stuck — show a hint
Join orders to customers.
Reference answer
SELECT o.order_id, c.customer_name, c.city FROM orders o JOIN customers c ON c.customer_id = o.customer_id ORDER BY o.order_id;| order_id | customer_name | city |
|---|---|---|
| 101 | Asha | Pune |
| 102 | Bilal | Mumbai |
| 103 | Asha | Pune |
| 104 | Charu | Pune |
| 105 | Dev | Delhi |
| 106 | Bilal | Mumbai |
| 107 | Esha | Mumbai |
| 108 | Dev | Delhi |
More than one query can be correct. If your result matches the target, your query is right.
List orders placed by customers from Mumbai.
Target: 3 rows: (102, Bilal); (106, Bilal); (107, Esha)
I’m stuck — show a hint
Join, then WHERE c.city.
Reference answer
SELECT o.order_id, c.customer_name FROM orders o JOIN customers c ON c.customer_id = o.customer_id WHERE c.city = 'Mumbai' ORDER BY o.order_id;| order_id | customer_name |
|---|---|
| 102 | Bilal |
| 106 | Bilal |
| 107 | Esha |
More than one query can be correct. If your result matches the target, your query is right.
List every customer and their order ids, keeping customers with no orders.
Target: 9 rows; first (Asha, 101), last (Farah, NULL)
I’m stuck — show a hint
LEFT JOIN from customers.
Reference answer
SELECT c.customer_name, o.order_id FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id ORDER BY c.customer_id, o.order_id;| customer_name | order_id |
|---|---|
| Asha | 101 |
| Asha | 103 |
| Bilal | 102 |
| Bilal | 106 |
| Charu | 104 |
| Dev | 105 |
| Dev | 108 |
| Esha | 107 |
| Farah | NULL |
More than one query can be correct. If your result matches the target, your query is right.
Show order 103 as a receipt: product, quantity, unit_price, line_total.
Target: 2 rows: (Pen, 10, 20.00, 200.00); (Keyboard, 1, 1200.00, 1200.00)
I’m stuck — show a hint
line_total = quantity * unit_price.
Reference answer
SELECT p.product_name, i.quantity, i.unit_price, i.quantity * i.unit_price AS line_total FROM order_items i JOIN products p ON p.product_id = i.product_id WHERE i.order_id = 103 ORDER BY p.product_id;| product_name | quantity | unit_price | line_total |
|---|---|---|---|
| Pen | 10 | 20.00 | 200.00 |
| Keyboard | 1 | 1200.00 | 1200.00 |
More than one query can be correct. If your result matches the target, your query is right.
Count orders per customer name, including customers with zero orders.
Target: 6 rows: Asha 2, Bilal 2, Charu 1, Dev 2, Esha 1, Farah 0
I’m stuck — show a hint
COUNT(o.order_id), not COUNT(*).
Reference answer
SELECT c.customer_name, COUNT(o.order_id) AS orders FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id GROUP BY c.customer_id, c.customer_name ORDER BY c.customer_id;| customer_name | orders |
|---|---|
| Asha | 2 |
| Bilal | 2 |
| Charu | 1 |
| Dev | 2 |
| Esha | 1 |
| Farah | 0 |
More than one query can be correct. If your result matches the target, your query is right.
Find products never sold in a completed order (a pending order does not count).
Target: 1 row: Planner
I’m stuck — show a hint
LEFT JOIN to a subquery of completed items, test for NULL.
Reference answer
SELECT p.product_name FROM products p LEFT JOIN (SELECT DISTINCT i.product_id FROM order_items i JOIN orders o ON o.order_id = i.order_id WHERE o.status = 'completed') s ON s.product_id = p.product_id WHERE s.product_id IS NULL;| product_name |
|---|
| Planner |
More than one query can be correct. If your result matches the target, your query is right.
Show completed revenue per product name.
Target: 5 rows: Keyboard 2400, Desk lamp 1600, Mug 1250, Notebook 600, Pen 300
I’m stuck — show a hint
Three tables: orders, order_items, products.
Reference answer
SELECT p.product_name, SUM(i.quantity * i.unit_price) AS revenue FROM orders o JOIN order_items i ON i.order_id = o.order_id JOIN products p ON p.product_id = i.product_id WHERE o.status = 'completed' GROUP BY p.product_id, p.product_name ORDER BY revenue DESC, p.product_id;| product_name | revenue |
|---|---|
| Keyboard | 2400.00 |
| Desk lamp | 1600.00 |
| Mug | 1250.00 |
| Notebook | 600.00 |
| Pen | 300.00 |
More than one query can be correct. If your result matches the target, your query is right.
Show completed revenue per category.
Target: 3 rows: Electronics 4000, Home 1250, Stationery 900
I’m stuck — show a hint
Group by category after joining.
Reference answer
SELECT p.category, SUM(i.quantity * i.unit_price) AS revenue FROM orders o JOIN order_items i ON i.order_id = o.order_id JOIN products p ON p.product_id = i.product_id WHERE o.status = 'completed' GROUP BY p.category ORDER BY revenue DESC;| category | revenue |
|---|---|
| Electronics | 4000.00 |
| Home | 1250.00 |
| Stationery | 900.00 |
More than one query can be correct. If your result matches the target, your query is right.
Show completed revenue per city.
Target: 3 rows: Mumbai 2600, Delhi 1850, Pune 1700
I’m stuck — show a hint
Join three tables.
Reference answer
SELECT c.city, SUM(i.quantity * i.unit_price) AS revenue FROM customers c JOIN orders o ON o.customer_id = c.customer_id JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY c.city ORDER BY revenue DESC;| city | revenue |
|---|---|
| Mumbai | 2600.00 |
| Delhi | 1850.00 |
| Pune | 1700.00 |
More than one query can be correct. If your result matches the target, your query is right.
Count completed orders per customer after joining to items — get 2 for Asha, not 4.
Target: 3 rows: Asha 2, Bilal 2, Dev 2
I’m stuck — show a hint
COUNT(DISTINCT o.order_id).
Reference answer
SELECT c.customer_name, COUNT(DISTINCT o.order_id) AS orders FROM customers c JOIN orders o ON o.customer_id = c.customer_id JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY c.customer_id, c.customer_name ORDER BY c.customer_id;| customer_name | orders |
|---|---|
| Asha | 2 |
| Bilal | 2 |
| Dev | 2 |
More than one query can be correct. If your result matches the target, your query is right.
List customers who have orders but no completed order.
Target: 2 rows: Charu, Esha
I’m stuck — show a hint
Group + HAVING on a completed-count.
Reference answer
SELECT c.customer_name FROM customers c JOIN orders o ON o.customer_id = c.customer_id GROUP BY c.customer_id, c.customer_name HAVING SUM(o.status = 'completed') = 0 ORDER BY c.customer_id;| customer_name |
|---|
| Charu |
| Esha |
More than one query can be correct. If your result matches the target, your query is right.
Show which products each customer bought in completed orders (distinct pairs).
Target: 10 rows; first (Asha, Keyboard), last (Dev, Notebook)
I’m stuck — show a hint
DISTINCT on name pairs.
Reference answer
SELECT DISTINCT c.customer_name, p.product_name FROM customers c JOIN orders o ON o.customer_id = c.customer_id JOIN order_items i ON i.order_id = o.order_id JOIN products p ON p.product_id = i.product_id WHERE o.status = 'completed' ORDER BY c.customer_name, p.product_name;| customer_name | product_name |
|---|---|
| Asha | Keyboard |
| Asha | Notebook |
| Asha | Pen |
| Bilal | Desk lamp |
| Bilal | Keyboard |
| Bilal | Mug |
| Bilal | Notebook |
| Dev | Desk lamp |
| Dev | Mug |
| Dev | Notebook |
More than one query can be correct. If your result matches the target, your query is right.
Find order lines where unit_price differs from the current catalogue price (price-change audit).
Target: 0 rows — every historical price matches; explain why the check is still worth keeping
I’m stuck — show a hint
Compare i.unit_price <> p.price.
Reference answer
SELECT i.order_id, p.product_name, i.unit_price, p.price FROM order_items i JOIN products p ON p.product_id = i.product_id WHERE i.unit_price <> p.price;| order_id | product_name | unit_price | price |
|---|---|---|---|
| Empty set (0 rows) | |||
More than one query can be correct. If your result matches the target, your query is right.
Status reconciliation: value of items in completed, cancelled and pending orders; total must be 7550.
Target: 3 rows: cancelled 800, completed 6150, pending 600
I’m stuck — show a hint
Group joined items by o.status.
Reference answer
SELECT o.status, SUM(i.quantity * i.unit_price) AS item_value FROM orders o JOIN order_items i ON i.order_id = o.order_id GROUP BY o.status ORDER BY o.status;| status | item_value |
|---|---|
| cancelled | 800.00 |
| completed | 6150.00 |
| pending | 600.00 |
More than one query can be correct. If your result matches the target, your query is right.
Practice mode runs in your browser on a SQLite engine with a MySQL compatibility layer, so you can practise without installing anything. Your result is compared with the reference answer on a fresh copy of the same rows, so any correct query is accepted. A few MySQL-only features (EXPLAIN columns, WITH ROLLUP, user variables) behave differently here — run those in MySQL Workbench.
Finished? Save your queries in a .sql file and mark the lesson complete.
Answers — Joins and grain
Easy. Attach customer names to every order. Target: 8 rows, not 14. Order 101 belongs to Asha.
SELECT o.order_id, c.customer_name FROM orders o JOIN customers c ON c.customer_id=o.customer_id ORDER BY o.order_id;
Medium. Find customers who have never placed any order. Target: Farah only. Esha has a pending order and must not be included.
SELECT c.customer_name FROM customers c LEFT JOIN orders o ON o.customer_id=c.customer_id WHERE o.order_id IS NULL;
Hard. Calculate each completed order total. Target: 101=300, 102=1300, 103=1400, 105=550, 106=1300, 108=1300; sum=6150.
SELECT o.order_id, SUM(i.quantity*i.unit_price) AS total FROM orders o JOIN order_items i ON i.order_id=o.order_id WHERE o.status='completed' GROUP BY o.order_id ORDER BY o.order_id;
Professional. Deliver completed order count and revenue in one audited result. Target: 6 orders and 6150 revenue. Explain why a raw COUNT(*) returns 12 item rows.
SELECT COUNT(DISTINCT o.order_id) AS orders, SUM(i.quantity*i.unit_price) AS revenue FROM orders o JOIN order_items i ON i.order_id=o.order_id WHERE o.status='completed';
Easy. Show each order item with the product name. Target: 14 rows; first (101, Notebook, 2), last (108, Mug, 2)
SELECT i.order_id, p.product_name, i.quantity FROM order_items i JOIN products p ON p.product_id = i.product_id ORDER BY i.order_id, p.product_id;
Easy. Show every order with customer name and city. Target: 8 rows; first (101, Asha, Pune), last (108, Dev, Delhi)
SELECT o.order_id, c.customer_name, c.city FROM orders o JOIN customers c ON c.customer_id = o.customer_id ORDER BY o.order_id;
Easy. List orders placed by customers from Mumbai. Target: 3 rows: (102, Bilal); (106, Bilal); (107, Esha)
SELECT o.order_id, c.customer_name FROM orders o JOIN customers c ON c.customer_id = o.customer_id WHERE c.city = 'Mumbai' ORDER BY o.order_id;
Easy. List every customer and their order ids, keeping customers with no orders. Target: 9 rows; first (Asha, 101), last (Farah, NULL)
SELECT c.customer_name, o.order_id FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id ORDER BY c.customer_id, o.order_id;
Medium. Show order 103 as a receipt: product, quantity, unit_price, line_total. Target: 2 rows: (Pen, 10, 20.00, 200.00); (Keyboard, 1, 1200.00, 1200.00)
SELECT p.product_name, i.quantity, i.unit_price, i.quantity * i.unit_price AS line_total FROM order_items i JOIN products p ON p.product_id = i.product_id WHERE i.order_id = 103 ORDER BY p.product_id;
Medium. Count orders per customer name, including customers with zero orders. Target: 6 rows: Asha 2, Bilal 2, Charu 1, Dev 2, Esha 1, Farah 0
SELECT c.customer_name, COUNT(o.order_id) AS orders FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id GROUP BY c.customer_id, c.customer_name ORDER BY c.customer_id;
Medium. Find products never sold in a completed order (a pending order does not count). Target: 1 row: Planner
SELECT p.product_name FROM products p LEFT JOIN (SELECT DISTINCT i.product_id FROM order_items i JOIN orders o ON o.order_id = i.order_id WHERE o.status = 'completed') s ON s.product_id = p.product_id WHERE s.product_id IS NULL;
Medium. Show completed revenue per product name. Target: 5 rows: Keyboard 2400, Desk lamp 1600, Mug 1250, Notebook 600, Pen 300
SELECT p.product_name, SUM(i.quantity * i.unit_price) AS revenue FROM orders o JOIN order_items i ON i.order_id = o.order_id JOIN products p ON p.product_id = i.product_id WHERE o.status = 'completed' GROUP BY p.product_id, p.product_name ORDER BY revenue DESC, p.product_id;
Medium. Show completed revenue per category. Target: 3 rows: Electronics 4000, Home 1250, Stationery 900
SELECT p.category, SUM(i.quantity * i.unit_price) AS revenue FROM orders o JOIN order_items i ON i.order_id = o.order_id JOIN products p ON p.product_id = i.product_id WHERE o.status = 'completed' GROUP BY p.category ORDER BY revenue DESC;
Hard. Show completed revenue per city. Target: 3 rows: Mumbai 2600, Delhi 1850, Pune 1700
SELECT c.city, SUM(i.quantity * i.unit_price) AS revenue FROM customers c JOIN orders o ON o.customer_id = c.customer_id JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY c.city ORDER BY revenue DESC;
Hard. Count completed orders per customer after joining to items — get 2 for Asha, not 4. Target: 3 rows: Asha 2, Bilal 2, Dev 2
SELECT c.customer_name, COUNT(DISTINCT o.order_id) AS orders FROM customers c JOIN orders o ON o.customer_id = c.customer_id JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY c.customer_id, c.customer_name ORDER BY c.customer_id;
Hard. List customers who have orders but no completed order. Target: 2 rows: Charu, Esha
SELECT c.customer_name FROM customers c JOIN orders o ON o.customer_id = c.customer_id GROUP BY c.customer_id, c.customer_name HAVING SUM(o.status = 'completed') = 0 ORDER BY c.customer_id;
Hard. Show which products each customer bought in completed orders (distinct pairs). Target: 10 rows; first (Asha, Keyboard), last (Dev, Notebook)
SELECT DISTINCT c.customer_name, p.product_name FROM customers c JOIN orders o ON o.customer_id = c.customer_id JOIN order_items i ON i.order_id = o.order_id JOIN products p ON p.product_id = i.product_id WHERE o.status = 'completed' ORDER BY c.customer_name, p.product_name;
Professional. Find order lines where unit_price differs from the current catalogue price (price-change audit). Target: 0 rows — every historical price matches; explain why the check is still worth keeping
SELECT i.order_id, p.product_name, i.unit_price, p.price FROM order_items i JOIN products p ON p.product_id = i.product_id WHERE i.unit_price <> p.price;
Professional. Status reconciliation: value of items in completed, cancelled and pending orders; total must be 7550. Target: 3 rows: cancelled 800, completed 6150, pending 600
SELECT o.status, SUM(i.quantity * i.unit_price) AS item_value FROM orders o JOIN order_items i ON i.order_id = o.order_id GROUP BY o.status ORDER BY o.status;
Up next · Lesson 9
Subqueries and CTEs
85 min · 19 practice problems