The INNER JOIN is the default JOIN, the one you already used without naming it in the previous lesson and the one you'll write in eight out of every ten queries of your professional life. Its rule is absolutely simple: only the rows that find a partner on both sides survive. Everything else disappears, silently and without warning.
And there's the trap. An INNER JOIN never raises an error for losing rows; it simply returns fewer than you expected. In this lesson you'll learn not only how to write it, but how to predict how many rows it returns and why, which is the skill that separates someone who understands JOINs from someone who copies them. You'll watch three GreenStore customers and half of its orders disappear, and you'll understand that those disappearances are the very definition of the operator.
Contents
INNER JOINversus plainJOIN- The set diagram
- Progressive examples over GreenStore
- The canonical four-table query
- Which rows get lost and why
- Extra conditions:
ONversusWHERE - Non-unique keys and row multiplication
- The order of the tables doesn't change the result
- Common Mistakes and Tips
- Exercises
- Conclusion
INNER JOIN versus plain JOIN
INNER JOIN versus plain JOINThe two forms below are exactly the same query:
INNER is optional because the SQL standard defines the unqualified JOIN as inner. It's the opposite of what happens with LEFT OUTER JOIN, where the optional word is OUTER.
| How it's written | Meaning | Is the word redundant? |
|---|---|---|
JOIN |
INNER JOIN |
— |
INNER JOIN |
Inner | Yes, INNER is optional |
LEFT JOIN |
LEFT OUTER JOIN |
— |
LEFT OUTER JOIN |
Outer on the left | Yes, OUTER is optional |
Which one should you write? There are two schools. One prefers JOIN for brevity; the other prefers INNER JOIN because, in a query that mixes several types, seeing the word INNER next to a LEFT makes the intent explicit and stops anyone thinking a modifier was forgotten.
Course convention: we'll write
JOINwhen the whole query is inner, and an explicitINNER JOINwhen it lives alongside aLEFT, aRIGHTor aFULLin the same query. It's the most widespread practice in professional teams.
- The set diagram
The classic way of visualising JOINs is with two overlapping sets. The INNER JOIN returns only the intersection:
flowchart LR
subgraph R[" "]
direction LR
A(("customers<br/>with no orders<br/>❌ out"))
I(("they match<br/>✅ result"))
B(("orders<br/>with no customer<br/>❌ out"))
end
style A fill:#f8f8f8,stroke:#bbb,stroke-dasharray: 4 4
style I fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px
style B fill:#f8f8f8,stroke:#bbb,stroke-dasharray: 4 4
And as a flow of rows, picking up the mental model from 03-01:
flowchart LR
A["left table"] --> C["cartesian"]
B["right table"] --> C
C --> D["ON filter"]
D --> E["✅ rows that match"]
D --> F["❌ rows with no partner<br/>discarded on both sides"]
That red box —"discarded on both sides"— is all you need to remember about the INNER JOIN. Lessons 03-03, 03-04 and 03-05 consist precisely of recovering what gets thrown away here.
- Progressive examples over GreenStore
3.1. A product and the name of its category
You already saw this in 03-01; we bring it back as a starting point, now extended with the supplier. Three tables, two ONs:
SELECT p.id,
p.name AS product,
cat.name AS category,
s.name AS supplier,
p.price
FROM products AS p
JOIN categories AS cat ON p.category_id = cat.id
JOIN suppliers AS s ON p.supplier_id = s.id
WHERE cat.name = 'Natural cosmetics'
ORDER BY p.id;| id | product | category | supplier | price |
|---|---|---|---|---|
| 6 | Aloe vera face cream 50 ml | Natural cosmetics | Maison Nature | 18.90 |
| 7 | Rosemary solid shampoo 80 g | Natural cosmetics | Maison Nature | 8.40 |
| 8 | Almond body oil 200 ml | Natural cosmetics | Verde Atlántico | 14.25 |
| 9 | Calendula lip balm 15 ml | Natural cosmetics | Maison Nature | 4.60 |
Notice a detail that's only possible with a JOIN: we've filtered by the category's name, not by its id. Before this module you'd have had to look up by hand that "Natural cosmetics" is category 2 and write WHERE category_id = 2. Now the query reads the way you think.
3.2. Orders with the customer's full name
The question we closed module 2 with: who placed each order.
SELECT o.id AS order_id,
o.order_date,
c.name || ' ' || c.last_name AS customer,
c.city,
o.status,
o.shipping_cost
FROM orders AS o
JOIN customers AS c ON o.customer_id = c.id
ORDER BY o.id;| order_id | order_date | customer | city | status | shipping_cost |
|---|---|---|---|---|---|
| 1 | 2025-03-04 | Lucía Martínez Soler | Valencia | delivered | 4.95 |
| 2 | 2025-03-12 | Carlos Ferrer Ibáñez | Valencia | delivered | 0.00 |
| 3 | 2025-04-02 | Marta Sanchis Gil | Castellón | delivered | 4.95 |
| 4 | 2025-04-19 | Javier Ortega Ruiz | Madrid | delivered | 4.95 |
| 5 | 2025-05-07 | Lucía Martínez Soler | Valencia | delivered | 0.00 |
| 6 | 2025-05-23 | Ana Belmonte Roca | Barcelona | cancelled | 4.95 |
| 7 | 2025-06-11 | Pau Llorens Vidal | Valencia | delivered | 6.50 |
| 8 | 2025-06-28 | Sofia Moreira Costa | Lisbon | delivered | 9.90 |
| 9 | 2025-07-15 | Tiago Almeida Nunes | Porto | delivered | 9.90 |
| 10 | 2025-08-03 | Camille Dubois | Lyon | delivered | 12.50 |
| 11 | 2025-09-09 | Carlos Ferrer Ibáñez | Valencia | delivered | 0.00 |
| 12 | 2025-10-01 | Julien Moreau | Paris | delivered | 12.50 |
| 13 | 2025-10-22 | Elena Navarro Puig | Alicante | delivered | 4.95 |
| 14 | 2025-11-14 | Diego Ramos Herrera | Seville | delivered | 4.95 |
| 15 | 2025-12-02 | Lucía Martínez Soler | Valencia | delivered | 0.00 |
| 16 | 2025-12-19 | Javier Ortega Ruiz | Madrid | shipped | 4.95 |
| 17 | 2026-01-13 | Sofia Moreira Costa | Lisbon | shipped | 9.90 |
| 18 | 2026-01-27 | Ana Belmonte Roca | Barcelona | paid | 4.95 |
| 19 | 2026-02-09 | Pau Llorens Vidal | Valencia | paid | 4.95 |
| 20 | 2026-02-21 | Camille Dubois | Lyon | pending | 12.50 |
20 rows, the same number orders has. There at last is "Camille Dubois" where there used to be a 9.
Two observations:
- Names repeat: Lucía Martínez Soler appears three times because she placed three orders (1, 5 and 15). That's normal: we're joining through the "many" side of the 1:N relationship.
- The concatenation
c.name || ' ' || c.last_nameis the one from 02-02, warning aboutNULLincluded. Here it's safe because both columns areNOT NULL.
3.3. Order lines with the product name and amount
SELECT ol.id AS line_id,
ol.order_id,
p.name AS product,
ol.quantity,
ol.unit_price,
ol.discount,
ROUND(ol.quantity * ol.unit_price * (1 - ol.discount), 2) AS amount
FROM order_lines AS ol
JOIN products AS p ON ol.product_id = p.id
ORDER BY ol.id
LIMIT 10;| line_id | order_id | product | quantity | unit_price | discount | amount |
|---|---|---|---|---|---|---|
| 1 | 1 | Extra virgin olive oil 500 ml | 2 | 11.95 | 0.00 | 23.90 |
| 2 | 1 | Organic brown rice 1 kg | 3 | 3.90 | 0.00 | 11.70 |
| 3 | 1 | Organic chamomile tea 20 bags | 2 | 3.25 | 0.00 | 6.50 |
| 4 | 2 | Aloe vera face cream 50 ml | 1 | 17.50 | 0.00 | 17.50 |
| 5 | 2 | Calendula lip balm 15 ml | 2 | 4.60 | 0.00 | 9.20 |
| 6 | 3 | Organic crushed tomato 400 g | 6 | 1.95 | 0.10 | 10.53 |
| 7 | 3 | Spelt pasta 500 g | 4 | 2.80 | 0.00 | 11.20 |
| 8 | 3 | Organic brown rice 1 kg | 2 | 3.90 | 0.00 | 7.80 |
| 9 | 4 | Ceremonial matcha green tea 30 g | 1 | 22.00 | 0.00 | 22.00 |
| 10 | 4 | Raw orange blossom honey 500 g | 1 | 9.75 | 0.00 | 9.75 |
(First 10 of 47 rows.)
Here you can see something we could only guess at in module 2: line 1 has unit_price 11.95, while the oil costs €12.50 today. It's the historical price 01-06 talked about. A classic mistake would be calculating the amount with p.price instead of ol.unit_price: you'd get €25.00 instead of €23.90 and you'd be rewriting the company's commercial history.
Course rule: a line's amount is always calculated with
ol.unit_price, never withp.price. Theproductstable says what it costs today;order_linessays what was charged back then.
- The canonical four-table query
This is the most important query of the course. It answers "what did each customer buy and for how much?" and it needs four tables: the detail is in order_lines, the customer hangs off orders and the product's name off products.
flowchart LR
OL["order_lines<br/>(detail: 47 rows)"] -->|"ol.order_id = o.id"| O["orders"]
O -->|"o.customer_id = c.id"| C["customers"]
OL -->|"ol.product_id = p.id"| P["products"]
Look at the shape of the path: it isn't a linear chain, it's a star with order_lines at the centre. Two branches come out of it: one towards the order and its customer, another towards the product. That's normal when the starting table has several foreign keys.
SELECT o.id AS order_id,
o.order_date,
c.name || ' ' || c.last_name AS customer,
p.name AS product,
ol.quantity,
ROUND(ol.quantity * ol.unit_price * (1 - ol.discount), 2) AS amount
FROM order_lines AS ol
JOIN orders AS o ON ol.order_id = o.id
JOIN customers AS c ON o.customer_id = c.id
JOIN products AS p ON ol.product_id = p.id
ORDER BY o.id, ol.id
LIMIT 12;| order_id | order_date | customer | product | quantity | amount |
|---|---|---|---|---|---|
| 1 | 2025-03-04 | Lucía Martínez Soler | Extra virgin olive oil 500 ml | 2 | 23.90 |
| 1 | 2025-03-04 | Lucía Martínez Soler | Organic brown rice 1 kg | 3 | 11.70 |
| 1 | 2025-03-04 | Lucía Martínez Soler | Organic chamomile tea 20 bags | 2 | 6.50 |
| 2 | 2025-03-12 | Carlos Ferrer Ibáñez | Aloe vera face cream 50 ml | 1 | 17.50 |
| 2 | 2025-03-12 | Carlos Ferrer Ibáñez | Calendula lip balm 15 ml | 2 | 9.20 |
| 3 | 2025-04-02 | Marta Sanchis Gil | Organic crushed tomato 400 g | 6 | 10.53 |
| 3 | 2025-04-02 | Marta Sanchis Gil | Spelt pasta 500 g | 4 | 11.20 |
| 3 | 2025-04-02 | Marta Sanchis Gil | Organic brown rice 1 kg | 2 | 7.80 |
| 4 | 2025-04-19 | Javier Ortega Ruiz | Ceremonial matcha green tea 30 g | 1 | 22.00 |
| 4 | 2025-04-19 | Javier Ortega Ruiz | Raw orange blossom honey 500 g | 1 | 9.75 |
| 5 | 2025-05-07 | Lucía Martínez Soler | Concentrated eco laundry detergent 1 L | 1 | 11.20 |
| 5 | 2025-05-07 | Lucía Martínez Soler | Loofah scrubber (pack of 3) | 2 | 11.00 |
(First 12 of 47 rows.)
47 rows: one per order line. Keep this query, because it's the basis of nearly everything that comes next. In module 4 you'll add GROUP BY c.id and SUM(amount) to answer "how much has each customer spent?"; in module 7 you'll use it as a subquery; in module 10 you'll turn it into a view. The skeleton doesn't change.
Four tables, three ON conditions. The rule from 03-01 holds again: N tables, N-1 pairings.
- Which rows get lost and why
Here we reach the heart of the lesson. An INNER JOIN silently discards partnerless rows, and in GreenStore that has two very visible consequences.
5.1. The three customers who disappear
customers has 15 rows. Let's see who survives an INNER JOIN with orders:
SELECT DISTINCT c.id,
c.name,
c.last_name
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
ORDER BY c.id;| id | name | last_name |
|---|---|---|
| 1 | Lucía | Martínez Soler |
| 2 | Carlos | Ferrer Ibáñez |
| 3 | Marta | Sanchis Gil |
| 4 | Javier | Ortega Ruiz |
| 5 | Ana | Belmonte Roca |
| 6 | Pau | Llorens Vidal |
| 7 | Sofia | Moreira Costa |
| 8 | Tiago | Almeida Nunes |
| 9 | Camille | Dubois |
| 10 | Julien | Moreau |
| 11 | Elena | Navarro Puig |
| 12 | Diego | Ramos Herrera |
12 rows, not 15. Customers 13 (Núria Bosch Ferrer), 14 (Hugo Iglesias Pardo) and 15 (Inés Carrasco Vega) are missing. Why? Because none of them has placed an order, so in the cartesian product there's no combination in which o.customer_id equals 13, 14 or 15. The ON condition doesn't find them and they disappear.
The balance of counts:
| Query | Rows | What it means |
|---|---|---|
SELECT ... FROM customers |
15 | Every customer |
SELECT ... FROM customers JOIN orders ON ... |
20 | One row per order, not per customer |
SELECT DISTINCT c.id ... FROM customers JOIN orders ON ... |
12 | Customers with at least one order |
The three figures are different and all three are correct: they answer different questions. If you're asked for "the list of customers with their activity" and you hand over 12 out of 15, you've erased three people from the report.
Warning: that
DISTINCTis exactly the symptom 02-04 talked about. It shows up here because we're using a tool —theINNER JOIN— that isn't the right one for the question "which customers have bought". The right tool arrives in 03-03.
5.2. The ten orders with no sales rep
The same phenomenon, from the other side. orders.employee_id allows NULL because web orders carry no assigned sales rep:
SELECT o.id AS order_id,
o.order_date,
e.name || ' ' || e.last_name AS sales_rep,
e.job_title
FROM orders AS o
JOIN employees AS e ON o.employee_id = e.id
ORDER BY o.id;| order_id | order_date | sales_rep | job_title |
|---|---|---|---|
| 2 | 2025-03-12 | Óscar Peris Blasco | Sales rep |
| 4 | 2025-04-19 | Laia Puig Sanchis | Sales rep |
| 6 | 2025-05-23 | Óscar Peris Blasco | Sales rep |
| 8 | 2025-06-28 | Laia Puig Sanchis | Sales rep |
| 10 | 2025-08-03 | Óscar Peris Blasco | Sales rep |
| 12 | 2025-10-01 | Laia Puig Sanchis | Sales rep |
| 14 | 2025-11-14 | Marc Estévez Roig | Customer support |
| 16 | 2025-12-19 | Óscar Peris Blasco | Sales rep |
| 18 | 2026-01-27 | Laia Puig Sanchis | Sales rep |
| 20 | 2026-02-21 | Marc Estévez Roig | Customer support |
10 rows out of 20. Orders 1, 3, 5, 7, 9, 11, 13, 15, 17 and 19 have disappeared: all the ones with employee_id IS NULL.
The mechanism is the one you saw in 02-03 with employee_id = NULL: NULL isn't equal to anything, not even to another NULL. The condition ON o.employee_id = e.id evaluates to NULL (which isn't TRUE) for those ten rows, so no partner saves them.
And here's the real damage: half of GreenStore's revenue has vanished from the report. If management asks "how many orders have we had this year?" and you answer with this query, your answer will be half the truth.
flowchart TD
A["20 orders"] --> B{"does employee_id<br/>have a value?"}
B -->|"yes (10)"| C["they find a partner<br/>✅ they appear in the result"]
B -->|"NULL (10)"| D["no comparison is TRUE<br/>❌ they are lost"]
5.3. The two causes of loss
To sum up, an INNER JOIN loses rows for two reasons:
| Cause | Example in GreenStore | Solution |
|---|---|---|
The FK is NULL |
The 10 orders with no employee_id |
LEFT JOIN from orders (03-03) |
| No child row points at this one | Customers 13, 14 and 15; products 13, 19 and 20 | LEFT JOIN from the parent table (03-03) |
In both cases the answer is the same family of operators, and that's why lessons 03-03, 03-04 and 03-05 exist. The INNER JOIN isn't wrong: it simply answers the question "give me what matches", and sometimes the business question is a different one.
- Extra conditions:
ON versus WHERE
ON versus WHERENothing stops you putting extra conditions inside the ON, beyond the matching. These two queries ask for the same thing: the orders of Portuguese customers.
-- Condition in the ON
SELECT o.id, o.order_date, c.name, c.country
FROM orders AS o
JOIN customers AS c
ON o.customer_id = c.id
AND c.country = 'Portugal'
ORDER BY o.id;| id | order_date | name | country |
|---|---|---|---|
| 8 | 2025-06-28 | Sofia | Portugal |
| 9 | 2025-07-15 | Tiago | Portugal |
| 17 | 2026-01-13 | Sofia | Portugal |
-- Condition in the WHERE
SELECT o.id, o.order_date, c.name, c.country
FROM orders AS o
JOIN customers AS c ON o.customer_id = c.id
WHERE c.country = 'Portugal'
ORDER BY o.id;| id | order_date | name | country |
|---|---|---|---|
| 8 | 2025-06-28 | Sofia | Portugal |
| 9 | 2025-07-15 | Tiago | Portugal |
| 17 | 2026-01-13 | Sofia | Portugal |
Identical results. And it's no coincidence: in an INNER JOIN they're always equivalent. Go back to the logical-order diagram from 03-01 and you'll see why. In an inner JOIN step 1d doesn't exist —no partnerless row is reintroduced—, so filtering during the matching or right afterwards produces the same set.
If they're equivalent, where should you put them? For readability:
| Kind of condition | Where to put it | Example |
|---|---|---|
| Relates two tables (matching) | ON |
o.customer_id = c.id |
| Restricts which rows we care about | WHERE |
c.country = 'Portugal' |
And now the most important warning of this lesson: this equivalence is EXCLUSIVE to the
INNER JOIN. In aLEFT JOIN, moving a condition from theONto theWHEREchanges the result, and it changes it in a way that looks like a data fault rather than a query fault. Lesson 03-03 demonstrates it with the same question written both ways and its two different results. Pick up the habit of separating matching from filtering right now: when you get to theLEFT JOIN, that habit will save you.
- Non-unique keys and row multiplication
So far every JOIN in the lesson has preserved the starting table's number of rows. That happens when you join from the "many" side towards the "one" side (from products to categories, from orders to customers): each row finds exactly one partner.
The other way round, things change.
SELECT o.id AS order_id,
o.order_date,
o.status,
ol.id AS line_id,
ol.product_id,
ol.quantity
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.id
WHERE o.id <= 3
ORDER BY o.id, ol.id;| order_id | order_date | status | line_id | product_id | quantity |
|---|---|---|---|---|---|
| 1 | 2025-03-04 | delivered | 1 | 1 | 2 |
| 1 | 2025-03-04 | delivered | 2 | 2 | 3 |
| 1 | 2025-03-04 | delivered | 3 | 14 | 2 |
| 2 | 2025-03-12 | delivered | 4 | 6 | 1 |
| 2 | 2025-03-12 | delivered | 5 | 9 | 2 |
| 3 | 2025-04-02 | delivered | 6 | 5 | 6 |
| 3 | 2025-04-02 | delivered | 7 | 4 | 4 |
| 3 | 2025-04-02 | delivered | 8 | 2 | 2 |
Three orders have produced eight rows. Order 1 appears three times (it has three lines), order 2 twice and order 3 three times. The header data —date, status, and also shipping_cost if we'd asked for it— repeats on every line.
The general rule:
When you join through a non-unique column on the right side, each row on the left is duplicated as many times as it finds partners.
JOIN direction |
Effect on the count | Example |
|---|---|---|
| Many → one (FK → PK) | It's preserved | order_lines JOIN products: 47 → 47 |
| One → many (PK → FK) | It's multiplied | orders JOIN order_lines: 20 → 47 |
| Many → many (no unique column) | It explodes | Careful |
Why this is the number 1 cause of inflated sums
This looks harmless while you're only looking at detail rows. It becomes dangerous in module 4, when you start adding things up. Picture this query:
-- ⚠️ INCORRECT (a preview of module 4): the shipping costs end up inflated
SELECT SUM(o.shipping_cost)
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.id;Order 1's shipping cost is €4.95 once, but after the JOIN that figure appears on three rows. The sum would give €14.85 for that order alone: three times the real value. And there'll be no error, no warning, just a wrong number in a management report.
The same would happen when summing salary after joining employees with orders, or when counting customers after joining customers with order_lines.
Learn to spot it now: after every
JOINyou add, ask yourself "how many rows on the right can there be for each row on the left?". If the answer is "more than one", any value from the left that you sum afterwards will be multiplied. In module 4 you'll see the techniques for avoiding it; for now it's enough to recognise the symptom.
A verification trick you can apply today: if your query's row count matches that of the detail table (47 for order_lines), you're at the right level of granularity to work with line amounts.
- The order of the tables doesn't change the result
In an INNER JOIN, A JOIN B and B JOIN A return the same set of rows. The operation is commutative.
-- These two are equivalent
FROM orders AS o JOIN customers AS c ON o.customer_id = c.id
FROM customers AS c JOIN orders AS o ON o.customer_id = c.idBoth return the same 20 rows. The only things that can change are the column order if you use SELECT *, and the row order if you leave out the ORDER BY (the usual story since 02-01).
With three or more tables it's also associative: (A JOIN B) JOIN C is equivalent to A JOIN (B JOIN C), as long as the ON conditions are correctly placed.
| Property | INNER JOIN |
LEFT JOIN |
|---|---|---|
Commutative (A ⋈ B = B ⋈ A) |
Yes | No |
| Associative | Yes | Only with care |
Being commutative has two practical consequences:
- Write the tables in the order the question is understood. If the report is "orders with their customer", start with
orders. If it's "customers and their orders", start withcustomers. The result is the same and the query reads better. - The optimizer ignores you. PostgreSQL freely reorders
INNER JOINs to pick the cheapest plan: it may start with the smallest table, or with the one carrying the most selective filter, regardless of how you wrote it. Your order is documentation for humans, not an instruction to the engine. (You'll see this in module 8.)
And a warning about what's coming. The LEFT JOIN is not commutative: A LEFT JOIN B and B LEFT JOIN A return different things. There the order in which you write the tables is part of the meaning, not of the style.
Common Mistakes and Tips
- Assuming the
INNER JOINpreserves every row. It loses the ones that don't match on either side, without saying a word. Check the count against the starting table. - Forgetting that a
NULLFK never matches. The 10 web orders disappear when joining withemployees.NULL = 4isn't false: it'sNULL, andNULLisn'tTRUE. - Calculating the amount with
p.priceinstead ofol.unit_price. It applies today's price to a sale from a year ago. With line 1 the difference is €1.10; with a real catalogue, thousands of euros. - Summing header values after joining with the detail.
SUM(o.shipping_cost)afterJOIN order_linescounts each order as many times as it has lines. It's the number 1 cause of inflated reports. - Using
DISTINCTas a patch. If you needDISTINCTto fix aJOIN, it's almost always theJOINthat's badly framed (02-04). Think about the granularity the question really wants. - Believing the
ON/WHEREequivalence is general. It only holds forINNER JOIN. In aLEFT JOINit changes the result (03-03). - Matching on columns of the right type but the wrong concept.
ON ol.product_id = o.idraises no error, compares integers with integers, and returns meaningless data. - Tip: validate each
JOINseparately. WriteFROM order_lines ol JOIN orders o ON ...withLIMIT 5first, check it, and only then add the third table. Debugging a five-table query written in one go is torture. - Tip: memorise GreenStore's three counts. 20 products, 20 orders, 47 lines. If a detail query doesn't return 47 rows, you know immediately that something's up.
- Tip: name your column aliases when joining tables with same-named columns.
c.name AS customerandp.name AS productstop the result having two columns calledname.
Exercises
Exercise 1
The product team wants to review the opinions received. Write a query that returns, for each review: its id, the name of the reviewed product, the full name of whoever wrote it, the rating and the date. Sort by review id.
Then answer: how many rows does it return, and why doesn't that number match the number of products in the catalogue?
Exercise 2
Administration needs the detail of the returns. Write a query showing, for each return: its id, the date, the amount, the reason, the id and status of the returned order, and the full name of the customer affected.
State how many tables you needed, how many ON conditions and why.
Exercise 3
Camille Dubois (customer 9) has called asking for the detail of everything she's bought. Using the canonical four-table query from section 4, get her complete history: order, date, order status, product, quantity and line amount.
Then answer these two questions:
- How many rows does it return and how many orders do they represent?
- If instead of an
INNER JOINbetweenordersandcustomersyou had joinedorderswithemployees, would the same Camille orders appear? Reason it out by looking at the data.
Solutions
Solution 1
SELECT r.id AS review_id,
p.name AS product,
c.name || ' ' || c.last_name AS customer,
r.rating,
r.date
FROM reviews AS r
JOIN products AS p ON r.product_id = p.id
JOIN customers AS c ON r.customer_id = c.id
ORDER BY r.id;| review_id | product | customer | rating | date |
|---|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | Lucía Martínez Soler | 5 | 2025-03-15 |
| 2 | Organic brown rice 1 kg | Lucía Martínez Soler | 4 | 2025-03-16 |
| 3 | Aloe vera face cream 50 ml | Carlos Ferrer Ibáñez | 5 | 2025-03-25 |
| 4 | Organic crushed tomato 400 g | Marta Sanchis Gil | 3 | 2025-04-12 |
| 5 | Ceremonial matcha green tea 30 g | Javier Ortega Ruiz | 5 | 2025-05-02 |
| 6 | Ginger kombucha 750 ml | Pau Llorens Vidal | 2 | 2025-06-20 |
| 7 | Extra virgin olive oil 500 ml | Sofia Moreira Costa | 5 | 2025-07-08 |
| 8 | Bamboo toothbrush | Tiago Almeida Nunes | 4 | 2025-07-26 |
| 9 | Aloe vera face cream 50 ml | Camille Dubois | 4 | 2025-08-14 |
| 10 | Organic brown rice 1 kg | Carlos Ferrer Ibáñez | 5 | 2025-09-19 |
| 11 | Reusable cotton bags (pack of 5) | Elena Navarro Puig | 3 | 2025-11-03 |
| 12 | Concentrated eco laundry detergent 1 L | Javier Ortega Ruiz | 4 | 2026-01-10 |
12 rows, exactly those of reviews. We start from that table and its two foreign keys (product_id, customer_id) are mandatory and valid: each review finds one product and one customer, one and only one.
Why it doesn't match the 20 products: because the query goes from reviews to products, not the other way round. Only the 9 distinct products that have some review appear (the oil, the rice and the cream appear twice each). The 11 products with no review at all —among them the deodorant and the spirulina capsules— don't exist for this query. To see them you'd have to start from products and use a LEFT JOIN: which is exactly what the next lesson does.
Solution 2
SELECT rt.id AS return_id,
rt.date,
rt.amount,
rt.reason,
o.id AS order_id,
o.status,
c.name || ' ' || c.last_name AS customer
FROM returns AS rt
JOIN orders AS o ON rt.order_id = o.id
JOIN customers AS c ON o.customer_id = c.id
ORDER BY rt.id;| return_id | date | amount | reason | order_id | status | customer |
|---|---|---|---|---|---|---|
| 1 | 2025-05-25 | 26.75 | Order cancelled by the customer before shipping | 6 | cancelled | Ana Belmonte Roca |
| 2 | 2025-08-11 | 34.02 | Product damaged in transit | 10 | delivered | Camille Dubois |
| 3 | 2025-10-30 | 19.80 | The format does not match what was expected | 13 | delivered | Elena Navarro Puig |
Three tables and two ON conditions, following the N-1 rule from 03-01. Three are needed because the customer isn't reachable from returns in a single hop: returns only knows order_id, and it's orders that knows customer_id. The compulsory path is returns → orders → customers.
A detail that validates the data: return 2 is worth €34.02, exactly the amount of line 24 (2 units of face cream at €18.90 with a 10 % discount). That's no coincidence: it was that particular product from Camille's order that was returned.
Solution 3
SELECT o.id AS order_id,
o.order_date,
o.status,
p.name AS product,
ol.quantity,
ROUND(ol.quantity * ol.unit_price * (1 - ol.discount), 2) AS amount
FROM order_lines AS ol
JOIN orders AS o ON ol.order_id = o.id
JOIN customers AS c ON o.customer_id = c.id
JOIN products AS p ON ol.product_id = p.id
WHERE c.id = 9
ORDER BY o.id, ol.id;| order_id | order_date | status | product | quantity | amount |
|---|---|---|---|---|---|
| 10 | 2025-08-03 | delivered | Aloe vera face cream 50 ml | 2 | 34.02 |
| 10 | 2025-08-03 | delivered | Almond body oil 200 ml | 1 | 14.25 |
| 20 | 2026-02-21 | pending | Organic brown rice 1 kg | 4 | 15.60 |
| 20 | 2026-02-21 | pending | Bamboo toothbrush | 2 | 7.00 |
1. 4 rows representing 2 orders: number 10 (delivered, two lines) and number 20 (pending, two lines). It's section 7's row multiplication in action: the number of rows is that of lines, not that of orders. If you wanted the number of Camille's orders, this query isn't the tool: you'd need module 4's COUNT(DISTINCT o.id).
2. No. Order 10 has employee_id = 4 (Óscar Peris Blasco) and would appear; order 20 has employee_id = 6 (Marc Estévez Roig) and would too. In this particular case both would indeed come out... but that's pure luck: both of Camille's orders have a sales rep assigned. Try it with Lucía Martínez Soler (customer 1), whose three orders —1, 5 and 15— are all web orders with employee_id IS NULL: an INNER JOIN with employees would make 100 % of her history disappear. That's the danger that motivates the next lesson.
Conclusion
The INNER JOIN holds no more secrets:
JOINandINNER JOINare the same thing. The course usesJOINin purely inner queries and an explicitINNER JOINwhen several types live together.- It returns only the intersection: the rows that find a partner on both sides. Anything that doesn't match is discarded silently.
- You know how to write chains of three and four tables and you have the canonical sales-detail query (
order_lines+orders+customers+products, 47 rows), which you'll reuse in modules 4, 7 and 10. - You know what gets lost: customers 13, 14 and 15 when joining
customerswithorders(12 customers out of 15), and the 10 web orders when joiningorderswithemployees(10 rows out of 20). The two causes are theNULLFK and the absence of a child row. - In an
INNER JOIN, putting an extra condition inONor inWHEREis equivalent; in aLEFT JOINit won't be, and that's the next lesson's trap. - Joining towards the "many" side multiplies rows: 20 orders become 47 once you add
order_lines. It's the number one cause of the inflated sums you'll see in module 4. - The
INNER JOINis commutative and associative: the order of the tables is a matter of readability, not of meaning. TheLEFT JOINwon't be.
In the next lesson, LEFT JOIN, we'll recover everything we've thrown away here. Núria, Hugo and Inés will come back with their empty orders; the soy candles, the deodorant and the spirulina that nobody has ever bought will come back; the ten web orders with their sales rep at NULL will come back. You'll learn the anti-join pattern for directly answering "which customers have never bought?" and you'll see, with the same query written two ways, why placing a condition in ON or in WHERE stops being a matter of indifference.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
