You closed module 2 hitting the same ceiling over and over: orders tells you customer_id = 9 and not "Camille Dubois", order_lines tells you product_id = 15 and not "Ceremonial matcha green tea". That ceiling ends here. In this lesson you'll learn what a JOIN really is, not as a formula you copy but as an operation you can rebuild in your head step by step: a cartesian product filtered by a condition. From that idea, everything else —INNER, LEFT, RIGHT, FULL, SELF, CROSS— stops being a list of names to memorise and becomes variations on a single mechanism.
This is the module's umbrella lesson: it gives you the conceptual frame, the syntax and the overview of the five types. Each of them is developed in depth in the lessons that follow.
Contents
- Why you have to put back together what normalisation split apart
- What a
JOINis: a filtered cartesian product - The
ONclause and the matching condition - Modern syntax versus the old comma form
- The accidental
CROSS JOIN USINGandNATURAL JOIN- Table aliases and name ambiguity
- An overview of the five types of
JOIN - Chaining three or more tables
JOINs in the logical execution order- Common Mistakes and Tips
- Exercises
- Conclusion
- Why you have to put back together what normalisation split apart
In lesson 01-05 we justified why the category name isn't stored inside each product: if "Natural cosmetics" appeared repeated across four rows of products, fixing a typo would require four UPDATEs, and forgetting just one would leave two versions of the same fact living side by side in the table. Normalisation solves that by storing the name once only, in categories, and leaving in products nothing but a numeric reference: category_id.
The price of that decision is exactly module 2's ceiling:
| id | name | category_id |
|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 1 |
| 2 | Organic brown rice 1 kg | 1 |
| 3 | Raw orange blossom honey 500 g | 1 |
A 1 is no use to anybody in a report. The fact exists, but it's spread across two tables, and you need an operation that puts them back together at query time.
The module's key idea: normalising means splitting apart so you can store well; the
JOINmeans putting back together so you can query well. They're two sides of the same coin, and that's why a well-designed database isn't an awkward database: it just asks you to learn how to walk its relationships.
- What a
JOIN is: a filtered cartesian product
JOIN is: a filtered cartesian productThe formal definition of a JOIN fits in one sentence:
A
JOINis the cartesian product of two tables, filtered by a condition.
The cartesian product pairs every row of the first table with every row of the second. If the first has 3 rows and the second 2, the result has 3 × 2 = 6.
Let's see it with two tiny extracts from GreenStore before touching the complete tables. These are the two working "tables":
Extract from categories (2 rows):
| id | name |
|---|---|
| 4 | Drinks |
| 5 | Personal hygiene |
Extract from products (3 rows):
| id | name | category_id | price |
|---|---|---|---|
| 16 | Ginger kombucha 750 ml | 4 | 4.95 |
| 17 | Cold-pressed orange juice 1 L | 4 | 5.40 |
| 18 | Bamboo toothbrush | 5 | 3.50 |
Step 1: the cartesian product
SELECT p.id AS product_id,
p.name AS product,
p.category_id,
cat.id AS cat_id,
cat.name AS category
FROM products AS p
CROSS JOIN categories AS cat
WHERE p.id IN (16, 17, 18)
AND cat.id IN (4, 5)
ORDER BY p.id, cat.id;| product_id | product | category_id | cat_id | category |
|---|---|---|---|---|
| 16 | Ginger kombucha 750 ml | 4 | 4 | Drinks |
| 16 | Ginger kombucha 750 ml | 4 | 5 | Personal hygiene |
| 17 | Cold-pressed orange juice 1 L | 4 | 4 | Drinks |
| 17 | Cold-pressed orange juice 1 L | 4 | 5 | Personal hygiene |
| 18 | Bamboo toothbrush | 5 | 4 | Drinks |
| 18 | Bamboo toothbrush | 5 | 5 | Personal hygiene |
6 rows. Every possible combination. Most of them are rubbish: the kombucha doesn't belong to "Personal hygiene" and the toothbrush isn't a drink.
Step 2: keeping only the correct combinations
The good rows are the ones where p.category_id matches cat.id. Look at the previous table with that criterion in mind:
| product_id | category_id | cat_id | category_id = cat_id? |
|---|---|---|---|
| 16 | 4 | 4 | ✅ yes |
| 16 | 4 | 5 | ❌ no |
| 17 | 4 | 4 | ✅ yes |
| 17 | 4 | 5 | ❌ no |
| 18 | 5 | 4 | ❌ no |
| 18 | 5 | 5 | ✅ yes |
Three rows survive, one per product. That's exactly what a JOIN does:
SELECT p.id AS product_id,
p.name AS product,
cat.name AS category
FROM products AS p
JOIN categories AS cat ON p.category_id = cat.id
WHERE p.id IN (16, 17, 18)
ORDER BY p.id;| product_id | product | category |
|---|---|---|
| 16 | Ginger kombucha 750 ml | Drinks |
| 17 | Cold-pressed orange juice 1 L | Drinks |
| 18 | Bamboo toothbrush | Personal hygiene |
Drawn as a flow of rows:
flowchart LR
A["products<br/>3 rows"] --> C["cartesian product<br/>3 × 2 = 6 rows"]
B["categories<br/>2 rows"] --> C
C --> D["ON filter<br/>p.category_id = cat.id"]
D --> E["result<br/>3 rows"]
An important nuance about performance. That the JOIN is defined this way doesn't mean the engine executes it this way. PostgreSQL doesn't materialise 300 rows just to throw 280 away: it uses algorithms such as hash join, merge join or nested loop that go straight to the pairs that match, usually leaning on indexes. It's the same distinction between logical order and physical plan you saw in 02-01, and you'll study it with EXPLAIN in module 8. To reason about what a query returns, the "cartesian + filter" mental model is always correct.
The full query over the 20 rows
With the trimming WHERE removed, the JOIN finally answers "which category is each product in?":
SELECT p.id,
p.name AS product,
cat.name AS category
FROM products AS p
JOIN categories AS cat ON p.category_id = cat.id
ORDER BY p.id;| id | product | category |
|---|---|---|
| 1 | Extra virgin olive oil 500 ml | Food |
| 2 | Organic brown rice 1 kg | Food |
| 3 | Raw orange blossom honey 500 g | Food |
| 4 | Spelt pasta 500 g | Food |
| 5 | Organic crushed tomato 400 g | Food |
| 6 | Aloe vera face cream 50 ml | Natural cosmetics |
| 7 | Rosemary solid shampoo 80 g | Natural cosmetics |
| 8 | Almond body oil 200 ml | Natural cosmetics |
| 9 | Calendula lip balm 15 ml | Natural cosmetics |
| 10 | Concentrated eco laundry detergent 1 L | Sustainable home |
| 11 | Loofah scrubber (pack of 3) | Sustainable home |
| 12 | Reusable cotton bags (pack of 5) | Sustainable home |
| 13 | Soy wax candles (pack of 2) | Sustainable home |
| 14 | Organic chamomile tea 20 bags | Drinks |
| 15 | Ceremonial matcha green tea 30 g | Drinks |
| 16 | Ginger kombucha 750 ml | Drinks |
| 17 | Cold-pressed orange juice 1 L | Drinks |
| 18 | Bamboo toothbrush | Personal hygiene |
| 19 | Natural stick deodorant 50 g | Personal hygiene |
| 20 | Spirulina capsules 120 units | Supplements |
20 rows, the same number products has. That count is no coincidence and deserves a rule you'll use constantly:
When you join a table with N rows against another one through a mandatory foreign key pointing at a primary key, the result has exactly N rows: every row finds one partner and only one. If the count changes, something isn't the way you thought.
- The
ON clause and the matching condition
ON clause and the matching conditionON holds the matching condition: the rule that decides which row on the left goes with which row on the right.
In 95 % of the cases you'll write in your life, that condition takes exactly this shape:
That is: FK = PK. The reason is obvious if you remember 01-05: the foreign key exists precisely to point at one particular row of the other table, so the natural path between two tables is the one the FK itself draws.
These are GreenStore's canonical matching conditions. Look them up whenever you're unsure:
| From | To | ON condition |
|---|---|---|
products |
categories |
p.category_id = cat.id |
products |
suppliers |
p.supplier_id = s.id |
orders |
customers |
o.customer_id = c.id |
orders |
employees |
o.employee_id = e.id |
order_lines |
orders |
ol.order_id = o.id |
order_lines |
products |
ol.product_id = p.id |
reviews |
products |
r.product_id = p.id |
reviews |
customers |
r.customer_id = c.id |
returns |
orders |
rt.order_id = o.id |
employees |
employees (manager) |
e.manager_id = manager.id |
customers |
customers (referrer) |
c.referred_by_id = referrer.id |
Although FK = PK equality is the usual case, ON accepts any boolean expression, just like WHERE:
-- Compound condition: several equalities joined by AND
ON r.product_id = ol.product_id AND r.date >= o.order_date
-- Inequality condition (non-equi join): you'll see it in 03-06
ON p1.category_id = p2.category_id AND p1.id < p2.idWhen the condition isn't an equality you talk about a non-equi join. They're a minority, but they exist: date ranges, price bands, comparisons between rows of the same table. You'll use the p1.id < p2.id case in lesson 03-06 to generate pairs of products without repetitions.
- Modern syntax versus the old comma form
Before the SQL-92 standard the word JOIN didn't exist. Tables were listed separated by commas in the FROM and the matching condition was written in the WHERE:
-- ⚠️ Old syntax (SQL-89). It works, but it's discouraged.
SELECT o.id,
o.order_date,
c.name,
c.last_name
FROM orders AS o, customers AS c
WHERE o.customer_id = c.id
AND o.status = 'pending';-- ✅ Modern syntax (SQL-92 onwards). The course's.
SELECT o.id,
o.order_date,
c.name,
c.last_name
FROM orders AS o
JOIN customers AS c ON o.customer_id = c.id
WHERE o.status = 'pending';| id | order_date | name | last_name |
|---|---|---|---|
| 20 | 2026-02-21 | Camille | Dubois |
Both return the same thing and PostgreSQL generates the same plan for either. But the old one has four serious problems:
| Problem | Explanation |
|---|---|
| It mixes two different things | The WHERE ends up containing matching conditions (o.customer_id = c.id) and filtering conditions (o.status = 'pending') all jumbled together. With six tables and twelve conditions, telling one kind from the other is an exercise in archaeology |
| It's easy to forget a condition | And forgetting one doesn't raise an error: it produces a silent cartesian product (section 5) |
It doesn't allow LEFT/RIGHT/FULL |
Outer joins, which are half of this module, have no expression in the comma syntax. Oracle had (+) and SQL Server *= as proprietary extensions, both obsolete today |
| It breaks the symmetry of the code | With the modern syntax, every table you add is a self-contained JOIN ... ON ... line. Adding or removing a table is a local edit |
Course rule: always
JOIN ... ON. TheFROMdescribes how the tables relate; theWHEREdescribes which rows we care about. They never get mixed. The only exception allowed is the deliberateCROSS JOIN, which we'll see in 03-06.
- The accidental
CROSS JOIN
CROSS JOINThis is the practical reason the comma syntax was abandoned. Forget the condition in the WHERE:
-- ⚠️ INCORRECT: the matching condition is missing
SELECT p.name AS product,
c.name AS customer
FROM products AS p, customers AS c;300 rows = 20 products × 15 customers. And worst of all: there's no error at all. The query runs, returns plausible-looking data and, if you put it in a report, that report will be wrong with nothing to give it away.
The same slip with the modern syntax is impossible to make without noticing:
-- ⚠️ INCORRECT, but this time the engine stops you
SELECT p.name, c.name
FROM products AS p
JOIN customers AS c;JOIN demands an ON (or a USING). If you really want the cartesian product you have to ask for it explicitly with CROSS JOIN, which is a statement of intent nobody writes by accident.
The size of the accident grows very fast:
| Table A | Table B | Rows in the cartesian product |
|---|---|---|
categories (6) |
suppliers (5) |
30 |
products (20) |
customers (15) |
300 |
order_lines (47) |
orders (20) |
940 |
| 100,000 | 100,000 | 10,000,000,000 |
That last row is the reason a badly written JOIN can bring a server down. In GreenStore it only translates into an absurd result; in production, into a phone call at three in the morning.
The unmistakable symptom: if a query returns far more rows than you expected and the data seems to repeat in a loop, count your
ONconditions. With N tables you need N-1 matching conditions. Three tables, twoONs. Five tables, fourONs.
USING and NATURAL JOIN
USING and NATURAL JOINSQL offers two shortcuts for writing less. One is useful with reservations; the other is a trap.
USING: when the columns share a name
If the matching column has the same name in both tables, USING (column) replaces the ON:
-- Equivalent when both tables have a column called product_id
JOIN reviews AS r ON ol.product_id = r.product_id
JOIN reviews AS r USING (product_id)USING has a property ON doesn't have: it merges the common column into one, instead of returning it twice. That's why you can write it unqualified:
SELECT product_id,
ol.id AS line_id,
ol.order_id,
r.id AS review_id,
r.rating
FROM order_lines AS ol
JOIN reviews AS r USING (product_id)
WHERE product_id = 15
ORDER BY ol.id;| product_id | line_id | order_id | review_id | rating |
|---|---|---|---|---|
| 15 | 9 | 4 | 5 | 5 |
| 15 | 28 | 12 | 5 | 5 |
| 15 | 42 | 17 | 5 | 5 |
In GreenStore USING is almost useless, and not by accident: the schema follows the convention that the primary key is called id and the foreign key <table>_id. Since products.id and order_lines.product_id don't share a name, USING doesn't apply. It only works between two child tables that share the FK's name, like the example above.
ON |
USING |
|
|---|---|---|
| Column names | May differ | Must be identical |
| Common column in the result | Appears twice | Appears once, merged |
| Compound conditions or inequalities | Yes | No: only lists of columns with equality |
| Use in GreenStore | Always | Exceptional |
NATURAL JOIN: the dangerous shortcut
NATURAL JOIN goes one step further: it automatically matches on every column that shares a name in both tables, without you saying which.
-- ⚠️ INCORRECT in practice: it doesn't do what it looks like
SELECT COUNT(*) AS rows_returned
FROM products NATURAL JOIN categories;| rows_returned |
|---|
| 0 |
Zero rows. (COUNT(*) simply counts the rows of the result; it's an aggregate function and it's studied in 04-04. Here we're using it as a measuring instrument, and it's the only time it'll appear in this module.)
Why zero? Because products and categories share two column names: id and name. NATURAL JOIN builds the condition on its own:
That is, it demands that the product and the category have the same identifier and the same name. No pair satisfies that. The query doesn't fail, doesn't warn: it returns a perfectly polite empty result.
And there's something worse than an empty result: a result that changes by itself. If tomorrow somebody adds an active column to categories, that NATURAL JOIN will start matching on active too and will return something else, without anybody having touched the query.
Course rule:
NATURAL JOINis banned. It's a shortcut that saves twenty characters in exchange for making your query's meaning depend on the column names somebody picks in the future. In this schema, on top of that,nameexists incategories,suppliers,products,customersandemployees: the minefield is already laid.
- Table aliases and name ambiguity
As soon as there are two tables in play, column names can repeat. And if they repeat, the engine doesn't guess:
-- ⚠️ INCORRECT
SELECT id, name
FROM products
JOIN categories ON products.category_id = categories.id;Both products and categories have id and name. PostgreSQL doesn't choose for you: it forces you to qualify the column with the name (or alias) of its table.
You could write it all out with the full table name:
SELECT products.id, products.name, categories.name
FROM products
JOIN categories ON products.category_id = categories.id;It works, but it's unbearably verbose as soon as there are four tables. Table aliases solve that:
-- ✅ CORRECT
SELECT p.id,
p.name AS product,
cat.name AS category
FROM products AS p
JOIN categories AS cat ON p.category_id = cat.id
ORDER BY p.id
LIMIT 3;| id | product | category |
|---|---|---|
| 1 | Extra virgin olive oil 500 ml | Food |
| 2 | Organic brown rice 1 kg | Food |
| 3 | Raw orange blossom honey 500 g | Food |
Notice that two different kinds of alias are needed here, and it's worth not confusing them:
| Kind | Where it goes | What it's for | Example |
|---|---|---|---|
| Table alias | In the FROM/JOIN |
Qualifying columns without writing out the whole name | products AS p |
| Column alias | In the SELECT |
Naming the result's column (02-02) | p.name AS product |
Without the column alias, the result would have two columns called name, and neither you nor your application would know which is which. It's exactly the problem we anticipated in 02-01 when talking about SELECT *.
The course's table aliases. So that every query in the syllabus reads the same way, we fix these abbreviations and we'll always use them:
| Table | Alias | Table | Alias |
|---|---|---|---|
products |
p |
orders |
o |
categories |
cat |
order_lines |
ol |
suppliers |
s |
reviews |
r |
customers |
c |
returns |
rt |
employees |
e |
And when the same table appears twice (the self joins of 03-06), aliases stop being a convenience and become mandatory, and we'll use meaningful names: employees AS e and employees AS manager, customers AS c and customers AS referrer.
Two syntax details:
- The
ASin table aliases is optional:FROM products pis identical toFROM products AS p. In this course we always write it, for consistency with column aliases. - Once the alias is defined, the original name stops being usable. If you write
FROM products AS p, you can't refer toproducts.price: it'll give youmissing FROM-clause entry for table "products".
- An overview of the five types of
JOIN
JOINEvery JOIN shares the mechanism from section 2. What changes is what happens to the rows that find no partner.
flowchart TD
Q{"What do I do with the rows<br/>that find no partner?"}
Q -->|"Discard them all"| I["INNER JOIN<br/>03-02"]
Q -->|"Keep the ones on the left"| L["LEFT JOIN<br/>03-03"]
Q -->|"Keep the ones on the right"| R["RIGHT JOIN<br/>03-04"]
Q -->|"Keep the ones on both sides"| F["FULL OUTER JOIN<br/>03-05"]
Q -->|"There is no condition:<br/>everything with everything"| C["CROSS JOIN<br/>03-06"]
In table form, with the typical GreenStore question each one answers:
| Type | What it returns | When to use it | Typical question | Lesson |
|---|---|---|---|---|
INNER JOIN |
Only the rows that match on both sides | When both sides are mandatory for the row to make sense | "Which category is each product in?" | 03-02 |
LEFT JOIN |
All of the left ones + the ones that match on the right (NULL when they don't) |
When the left table is the protagonist and the right one is optional | "What has each customer ordered, including those who have ordered nothing?" | 03-03 |
RIGHT JOIN |
All of the right ones + the ones that match on the left | The same, with the roles reversed | "Which orders did each employee handle, including those who handled none?" | 03-04 |
FULL OUTER JOIN |
All of them on both sides | Reconciling two sources that may each have items the other lacks | "What's in the catalogue that isn't in sales, and what's in sales that isn't in the catalogue?" | 03-05 |
CROSS JOIN |
Every combination, with no condition | Deliberately generating combinations (calendars, matrices) | "Give me every category × month pair, even where there were no sales" | 03-06 |
To those five you add the SELF JOIN, which isn't a sixth type but a technique: using any of the previous ones to join a table to itself, and so walk the reflexive relationships of employees.manager_id and customers.referred_by_id. That's 03-06 too.
A note on vocabulary you'll see in the documentation: INNER JOIN is the inner join; LEFT, RIGHT and FULL are the outer joins, because they keep rows that fall "outside" the matching. Hence their full names are LEFT OUTER JOIN, RIGHT OUTER JOIN and FULL OUTER JOIN; the word OUTER is optional in all three.
- Chaining three or more tables
Business questions are rarely answered with two tables. Chaining is simply adding one JOIN ... ON ... line per new table, and it works because the result of a JOIN is itself a table that can be joined again.
flowchart LR
A["order_lines"] -->|"ol.order_id = o.id"| B["orders"]
B -->|"o.customer_id = c.id"| C["customers"]
Chain 1: from the line to the customer
"Who bought each order line?" The customer's name isn't in order_lines and isn't reachable in one hop: you have to go through orders.
SELECT ol.id AS line_id,
o.id AS order_id,
c.name || ' ' || c.last_name AS customer,
ol.product_id,
ol.quantity
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
ORDER BY ol.id
LIMIT 10;| line_id | order_id | customer | product_id | quantity |
|---|---|---|---|---|
| 1 | 1 | Lucía Martínez Soler | 1 | 2 |
| 2 | 1 | Lucía Martínez Soler | 2 | 3 |
| 3 | 1 | Lucía Martínez Soler | 14 | 2 |
| 4 | 2 | Carlos Ferrer Ibáñez | 6 | 1 |
| 5 | 2 | Carlos Ferrer Ibáñez | 9 | 2 |
| 6 | 3 | Marta Sanchis Gil | 5 | 6 |
| 7 | 3 | Marta Sanchis Gil | 4 | 4 |
| 8 | 3 | Marta Sanchis Gil | 2 | 2 |
| 9 | 4 | Javier Ortega Ruiz | 15 | 1 |
| 10 | 4 | Javier Ortega Ruiz | 3 | 1 |
(First 10 of 47 rows.)
Three tables, two ON conditions, and the count is still 47: the same as order_lines has. Notice that the customer's name repeats across the first three rows, because order 1 has three lines. That isn't a mistake: it's the natural consequence of joining through the "many" side of a 1:N relationship, and in 03-02 you'll see why it's the number one cause of module 4's inflated sums.
Chain 2: from the line to the category
flowchart LR
A["order_lines"] -->|"ol.product_id = p.id"| B["products"]
B -->|"p.category_id = cat.id"| C["categories"]
SELECT ol.id AS line_id,
p.name AS product,
cat.name AS category,
ol.quantity,
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
JOIN categories AS cat ON p.category_id = cat.id
ORDER BY ol.id
LIMIT 10;| line_id | product | category | quantity | amount |
|---|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | Food | 2 | 23.90 |
| 2 | Organic brown rice 1 kg | Food | 3 | 11.70 |
| 3 | Organic chamomile tea 20 bags | Drinks | 2 | 6.50 |
| 4 | Aloe vera face cream 50 ml | Natural cosmetics | 1 | 17.50 |
| 5 | Calendula lip balm 15 ml | Natural cosmetics | 2 | 9.20 |
| 6 | Organic crushed tomato 400 g | Food | 6 | 10.53 |
| 7 | Spelt pasta 500 g | Food | 4 | 11.20 |
| 8 | Organic brown rice 1 kg | Food | 2 | 7.80 |
| 9 | Ceremonial matcha green tea 30 g | Drinks | 1 | 22.00 |
| 10 | Raw orange blossom honey 500 g | Food | 1 | 9.75 |
(First 10 of 47 rows.)
There it finally is: the readable sales detail. What was sold, from which category and for how much. In module 4 we'll add up those amounts by category and answer "which category bills the most?"; for now we stay at the detail rows, which is what this module knows how to produce.
Notice how the amount is calculated following the convention from 02-02: you operate at full precision inside the ROUND and round only when presenting. Line 6 illustrates it: 6 * 1.95 * 0.90 = 10.53.
How to build a chain, in three steps. This method will save you from nearly every mistake:
- Find the starting point: the table that holds the level of detail you want (one row per order line →
order_lines). - Trace the path on the ER diagram from 01-06 to every piece of data you need, following the arrows of the foreign keys.
- Write one
JOIN ... ONper hop, in the order of the path, and check the row count at the end.
JOINs in the logical execution order
JOINs in the logical execution orderHere comes the extension of the diagram we've been carrying since 02-01, and it's the most important piece of the lesson for what comes next.
The question it answers is: at what moment are JOINs resolved? Answer: inside the FROM step, before WHERE.
flowchart TD
subgraph FROM["1 · FROM — the starting set is built"]
direction LR
A1["1a · base tables"] --> A2["1b · cartesian product<br/>of each pair"]
A2 --> A3["1c · ON condition<br/>filters the pairings"]
A3 --> A4["1d · rows with no partner<br/>are added back (LEFT/RIGHT/FULL)"]
end
FROM --> B["2 · WHERE<br/>filters rows of the already joined result"]
B --> C["3 · SELECT<br/>projects and computes<br/>aliases are born here"]
C --> D["3b · DISTINCT<br/>removes duplicates"]
D --> E["4 · ORDER BY<br/>sorts the result"]
E --> F["5 · LIMIT / OFFSET<br/>trims"]
Read it slowly, because three consequences that govern the whole module come out of it:
1. ON and WHERE run at different moments. ON acts while the pairing is being built; WHERE acts afterwards, over the already combined table.
2. In an INNER JOIN that difference doesn't show. Putting o.status = 'delivered' in the ON or in the WHERE gives exactly the same result, because in an INNER JOIN there's no step 1d: the rows with no partner are discarded either way. You'll check it in 03-02.
3. In a LEFT JOIN the difference is enormous. Step 1d reintroduces the partnerless rows after applying the ON, but before applying the WHERE. The result:
| Where you put the condition | What happens in a LEFT JOIN |
|---|---|
In the ON |
It restricts what gets matched. The left-hand rows with no partner still appear, with NULLs on the right |
In the WHERE |
It filters the final result. The rows with NULLs on the right don't satisfy the condition and disappear: the LEFT JOIN silently degrades into an INNER JOIN |
It's the most frequent and hardest-to-spot mistake in all of intermediate SQL, and lesson 03-03 demonstrates it with the same query written both ways and its two different results. Keep this diagram to hand when you get there.
One final note on the ordering inside the FROM itself: when you chain several JOINs, they're resolved left to right. A JOIN B JOIN C means (A JOIN B) JOIN C: first A is joined with B, and the result is joined with C. With pure INNER JOINs the order is irrelevant (03-02); with chained LEFT JOINs, it's anything but (03-03).
Common Mistakes and Tips
- Forgetting the
ONcondition when using the comma syntax. It produces a silent cartesian product: lots of rows, no error. With N tables you need N-1 matching conditions. column reference "id" is ambiguous. Two tables in theFROMhave a column with that name. Qualify it:p.id,cat.id. It always happens withidand withnamein this schema.missing FROM-clause entry for table "products". You defined the aliaspand then wroteproducts.price. Once there's an alias, the original name no longer exists for that query.- Using
NATURAL JOIN"because it's shorter". It matches on every same-named column, including the ones somebody adds tomorrow. Inproducts NATURAL JOIN categoriesit returns 0 rows because ofname. - Matching on the wrong column.
ON ol.product_id = o.idcompiles perfectly and returns rubbish: you're comparing a product identifier with an order one. Always check that both sides of the equality are talking about the same entity. - Assuming a
JOINnever changes the number of rows. It's only preserved when you join through a mandatory FK against a PK. If the FK allowsNULL, you lose rows (03-03); if the right side has several rows per left-hand one, you multiply them (03-02). - Mixing old and modern syntax in the same query.
FROM a, b JOIN c ON ...is legal and it's a precedence minefield. Don't do it. - Tip: write the
JOINbefore theSELECT. Build theFROMwith its chains first, run it withSELECT *andLIMIT 5to see the shape of the result, and only then choose the columns. - Tip: count the rows after every
JOINyou add. If you add a table and the count shoots up, that table has several rows per previous row. If it drops to zero, yourONcondition matches nothing. - Tip: keep the ER diagram from 01-06 open.
JOINs are paths through that diagram. Nobody memorises them; you read them.
Exercises
Exercise 1
Purchasing needs to know what share of the catalogue depends on foreign suppliers. Write a query that returns, only for products whose supplier isn't from Spain: the product's id and name, its price, the supplier's name and their country. Sort by country and, within each country, by product id.
Then answer: why does the condition on the country go in the WHERE and not in the ON?
Exercise 2
This query, written by a colleague, lands on your desk:
SELECT o.id, o.order_date, c.name, c.last_name, c.city
FROM orders o, customers c
WHERE o.customer_id = c.id
AND c.country = 'France'
AND o.status = 'delivered';- Rewrite it with the modern
JOIN ... ONsyntax, clearly separating matching from filtering. - What exactly would happen if somebody accidentally deleted the line
o.customer_id = c.idin the original version? Work out how many rows it would return.
Exercise 3
Without running anything, predict the number of rows each of these queries returns and justify each prediction in one sentence. Then run them and check.
-- a)
SELECT * FROM products AS p
JOIN categories AS cat ON p.category_id = cat.id;
-- b)
SELECT * FROM products AS p, categories AS cat;
-- c)
SELECT * FROM order_lines AS ol
JOIN orders AS o ON ol.order_id = o.id;
-- d)
SELECT * FROM orders AS o
JOIN employees AS e ON o.employee_id = e.id;
-- e)
SELECT * FROM reviews AS r
JOIN customers AS c ON r.customer_id = c.id;Solutions
Solution 1
SELECT p.id,
p.name AS product,
p.price,
s.name AS supplier,
s.country
FROM products AS p
JOIN suppliers AS s ON p.supplier_id = s.id
WHERE s.country <> 'Spain'
ORDER BY s.country, p.id;| id | product | price | supplier | country |
|---|---|---|---|---|
| 6 | Aloe vera face cream 50 ml | 18.90 | Maison Nature | France |
| 7 | Rosemary solid shampoo 80 g | 8.40 | Maison Nature | France |
| 9 | Calendula lip balm 15 ml | 4.60 | Maison Nature | France |
| 19 | Natural stick deodorant 50 g | 7.80 | Maison Nature | France |
| 10 | Concentrated eco laundry detergent 1 L | 11.20 | EcoNordic Supplies | Germany |
| 13 | Soy wax candles (pack of 2) | 13.75 | EcoNordic Supplies | Germany |
| 18 | Bamboo toothbrush | 3.50 | EcoNordic Supplies | Germany |
| 20 | Spirulina capsules 120 units | 16.40 | EcoNordic Supplies | Germany |
| 8 | Almond body oil 200 ml | 14.25 | Verde Atlántico | Portugal |
| 11 | Loofah scrubber (pack of 3) | 5.50 | Verde Atlántico | Portugal |
| 12 | Reusable cotton bags (pack of 5) | 9.90 | Verde Atlántico | Portugal |
| 15 | Ceremonial matcha green tea 30 g | 22.00 | Verde Atlántico | Portugal |
12 rows of the catalogue's 20: 8 products come from the two Spanish suppliers (Huerta del Turia and BioSierra Ibérica) and the other 12 from France, Germany and Portugal.
Why the condition goes in the WHERE: s.country <> 'Spain' doesn't say how the tables are matched —that's already said by p.supplier_id = s.id—, it says which rows of the result we care about. It's the separation of responsibilities from section 4: ON to match, WHERE to filter.
That said, in this particular case putting it in the ON would give the same result, because it's an INNER JOIN and steps 1c and 2 of the logical order behave identically when there are no orphan rows to reintroduce. The distinction becomes critical in the LEFT JOIN of 03-03, and that's why it's worth picking up the right habit from now on.
Solution 2
1. Modern rewrite:
SELECT o.id,
o.order_date,
c.name,
c.last_name,
c.city
FROM orders AS o
JOIN customers AS c ON o.customer_id = c.id
WHERE c.country = 'France'
AND o.status = 'delivered';| id | order_date | name | last_name | city |
|---|---|---|---|---|
| 10 | 2025-08-03 | Camille | Dubois | Lyon |
| 12 | 2025-10-01 | Julien | Moreau | Paris |
The two delivered orders from French customers. The rewrite makes visible what the old version hid: a single matching condition and two filtering ones.
2. If o.customer_id = c.id is deleted:
The query becomes a cartesian product filtered only by country and status. The arithmetic:
- Customers from France: 2 (Camille Dubois and Julien Moreau).
- Orders with status
delivered: 14. - Resulting rows: 2 × 14 = 28.
And they'd be 28 false rows: every delivered order would appear associated with both French customers, including the orders that in reality belong to Lucía, to Sofia or to Tiago. No error, no warning, a completely invented report. It's exactly the danger from section 5.
Solution 3
| # | Rows | Justification |
|---|---|---|
| a) | 20 | Every product has a category_id pointing at an existing category: one partner per product. The starting table's count is preserved |
| b) | 120 | Cartesian product: 20 products × 6 categories. There's no condition to filter it |
| c) | 47 | Every line belongs to an order and order_id is NOT NULL: one partner per line. order_lines's count is preserved |
| d) | 10 | Here rows are lost. Of the 20 orders, 10 have employee_id IS NULL (the web orders). A NULL isn't equal to anything —not even to another NULL, as you saw in 02-03—, so those 10 rows find no partner and the INNER JOIN discards them |
| e) | 12 | Every review has a mandatory, valid customer_id: one partner per review |
Case d) is the most instructive of the five and the reason lessons 03-03 and 03-04 exist: if management asks you for "the list of orders with their sales rep" and you hand over 10 rows out of 20, you've lost half the business without noticing.
Conclusion
You now have the module's complete conceptual frame:
- Normalisation splits apart so you can store without redundancy; the
JOINputs back together so you can query. They're complementary, not contradictory. - A
JOINis a cartesian product filtered by a condition. That mental model —combine everything with everything and keep what matches— explains the behaviour of every type ofJOIN, even though the engine executes it far more efficiently. - The condition goes in
ONand nearly always takes the form FK = PK. You have the table of GreenStore's eleven canonical conditions to look up whenever you're unsure. - The modern
JOIN ... ONsyntax replaces the old comma form: it separates matching from filtering, prevents the accidentalCROSS JOINand is the only one that supports outerJOINs. USINGonly helps when the columns share a name (rare in this schema) andNATURAL JOINis banned: inproducts NATURAL JOIN categoriesit returns 0 rows because of thenamecolumn.- Table aliases are mandatory in practice: without them come the
column reference "id" is ambiguouserrors. The course's are fixed:p,cat,s,c,e,o,ol,r,rt. - You know the overview of the five types and you know the only difference between them is what happens to the rows that find no partner.
- You know how to chain three or more tables by following the ER diagram, with N-1
ONconditions for N tables, and you have the two paths you'll use all course long:order_lines → orders → customersandorder_lines → products → categories. - And you know that, in the logical order,
JOINs are resolved inside theFROMstep, before theWHERE: that's where the critical difference between putting a condition inONor inWHEREwill come from.
In the next lesson, INNER JOIN, we'll go down into the detail of the type you've just used without naming it: which rows it keeps, which it loses and why. You'll watch customers 13, 14 and 15 disappear when joining customers with orders, and the 10 web orders disappear when joining orders with employees. Those disappearances, far from being a fault, are the very definition of the INNER JOIN, and understanding them is what makes the LEFT JOIN of 03-03 make sense.
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
