In the previous lesson you lost three customers, three products and ten orders without anybody warning you. The LEFT JOIN is the tool that gets them back. Its rule is a single sentence: it keeps every row of the left table, whether or not it matches the right one; when they don't match, the right-hand columns are filled with NULL.
It sounds simple, and it is. But the LEFT JOIN hides the most famous trap in all of intermediate SQL: putting a condition in the ON or in the WHERE stops being a matter of indifference. The difference raises no error, gives no warning, and produces two different results that look equally plausible. Half of this lesson is devoted to making sure you never fall for it.
Contents
- The
LEFT JOINrule and the set diagram LEFT JOIN=LEFT OUTER JOIN- Where exactly the
NULLs come from - GreenStore's three real cases
- The anti-join pattern: finding what doesn't match
- The trap: a condition in
ONversus a condition inWHERE - Chained
LEFT JOINs - When the right table has several rows per left-hand one
- Common Mistakes and Tips
- Exercises
- Conclusion
- The
LEFT JOIN rule and the set diagram
LEFT JOIN rule and the set diagramflowchart LR
subgraph R[" "]
direction LR
A(("only on the left<br/>✅ kept<br/>with NULL on the right"))
I(("they match<br/>✅ result"))
B(("only on the right<br/>❌ out"))
end
style A fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px
style I fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px
style B fill:#f8f8f8,stroke:#bbb,stroke-dasharray: 4 4
As a flow of rows, extending the diagram from 03-02:
flowchart LR
A["left table"] --> C["cartesian"]
B["right table"] --> C
C --> D["ON filter"]
D --> E["✅ rows that match"]
D --> F["left rows<br/>with no partner"]
F --> G["✅ added anyway<br/>with NULL on the right"]
D --> H["❌ right rows<br/>with no partner: discarded"]
That extra step —"added anyway with NULL on the right"— is step 1d of the logical order you saw in 03-01. It happens inside the FROM, after applying the ON and before the WHERE. Everything this lesson explains follows from that fact.
The word "left" is literal: it's the table you write before the words LEFT JOIN.
FROM customers AS c -- ← the left: kept in full
LEFT JOIN orders AS o -- ← the right: only contributes what matches
ON o.customer_id = c.idFrom this comes the most important consequence for writing well: the LEFT JOIN isn't commutative. customers LEFT JOIN orders keeps all 15 customers; orders LEFT JOIN customers keeps all 20 orders. They're different questions.
How to choose the left side: say the business question out loud and look for the noun that must appear complete in the report. "I want every customer, with their orders if they have any" →
customersis the left. "I want every order, with its sales rep if there is one" →ordersis the left.
LEFT JOIN = LEFT OUTER JOIN
LEFT JOIN = LEFT OUTER JOINThe two spellings are identical:
FROM customers AS c LEFT JOIN orders AS o ON o.customer_id = c.id
FROM customers AS c LEFT OUTER JOIN orders AS o ON o.customer_id = c.idOUTER is optional and practically nobody writes it. The adjective outer describes the whole family: LEFT, RIGHT and FULL are outer joins because they keep rows that fall outside the matching; INNER is the inner join because it only returns what stays inside.
| Spelling | Equivalent to | Real-world frequency |
|---|---|---|
LEFT JOIN |
LEFT OUTER JOIN |
The usual one |
LEFT OUTER JOIN |
LEFT JOIN |
Uncommon, a bit more so in formal documentation |
In this course we write LEFT JOIN.
- Where exactly the
NULLs come from
NULLs come fromThis point is often misread, so it's worth being precise: the NULLs that appear in a LEFT JOIN weren't in the database. They're manufactured by the engine at the moment the result is built.
When a left-hand row finds no partner, PostgreSQL adds it to the result anyway and fills all of the right table's columns with NULL. Not just the key one: all of them.
flowchart LR
A["customer 13<br/>Núria Bosch Ferrer"] --> B{"any order<br/>with customer_id = 13?"}
B -->|"no"| C["row kept<br/>o.id = NULL<br/>o.order_date = NULL<br/>o.status = NULL<br/>o.shipping_cost = NULL"]
Two consequences you'll use constantly follow from that:
- You can detect the absence by looking at any column on the right. If
o.id IS NULLin aLEFT JOINfromcustomers, it means there was no partner. It's the basis of section 5's anti-join. - You should look at a
NOT NULLcolumn on the right. If you picked a column that can genuinely be null in the data, you wouldn't be able to tell "there was no partner" from "there was a partner and that value was empty". That's why the anti-join is always written against the right table's primary key:o.id,ol.id,r.id. A PK is never legitimatelyNULL.
A third, subtler effect is the null arithmetic you saw in 02-02: any operation involving NULL gives NULL. If you compute o.shipping_cost * 2 over a partnerless row, the result is NULL, not zero. The COALESCE function that solves this is studied in 06-04; the complete treatment of nulls, in 04-03.
- GreenStore's three real cases
The dataset's three deliberate gaps exist precisely for this lesson.
4.1. Every customer with their orders
SELECT c.id AS customer_id,
c.name || ' ' || c.last_name AS customer,
c.city,
o.id AS order_id,
o.order_date,
o.status
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
ORDER BY c.id, o.id;| customer_id | customer | city | order_id | order_date | status |
|---|---|---|---|---|---|
| 1 | Lucía Martínez Soler | Valencia | 1 | 2025-03-04 | delivered |
| 1 | Lucía Martínez Soler | Valencia | 5 | 2025-05-07 | delivered |
| 1 | Lucía Martínez Soler | Valencia | 15 | 2025-12-02 | delivered |
| 2 | Carlos Ferrer Ibáñez | Valencia | 2 | 2025-03-12 | delivered |
| 2 | Carlos Ferrer Ibáñez | Valencia | 11 | 2025-09-09 | delivered |
| 3 | Marta Sanchis Gil | Castellón | 3 | 2025-04-02 | delivered |
| 4 | Javier Ortega Ruiz | Madrid | 4 | 2025-04-19 | delivered |
| 4 | Javier Ortega Ruiz | Madrid | 16 | 2025-12-19 | shipped |
| 5 | Ana Belmonte Roca | Barcelona | 6 | 2025-05-23 | cancelled |
| 5 | Ana Belmonte Roca | Barcelona | 18 | 2026-01-27 | paid |
| 6 | Pau Llorens Vidal | Valencia | 7 | 2025-06-11 | delivered |
| 6 | Pau Llorens Vidal | Valencia | 19 | 2026-02-09 | paid |
| 7 | Sofia Moreira Costa | Lisbon | 8 | 2025-06-28 | delivered |
| 7 | Sofia Moreira Costa | Lisbon | 17 | 2026-01-13 | shipped |
| 8 | Tiago Almeida Nunes | Porto | 9 | 2025-07-15 | delivered |
| 9 | Camille Dubois | Lyon | 10 | 2025-08-03 | delivered |
| 9 | Camille Dubois | Lyon | 20 | 2026-02-21 | pending |
| 10 | Julien Moreau | Paris | 12 | 2025-10-01 | delivered |
| 11 | Elena Navarro Puig | Alicante | 13 | 2025-10-22 | delivered |
| 12 | Diego Ramos Herrera | Seville | 14 | 2025-11-14 | delivered |
| 13 | Núria Bosch Ferrer | Barcelona | (null) | (null) | (null) |
| 14 | Hugo Iglesias Pardo | Zaragoza | (null) | (null) | (null) |
| 15 | Inés Carrasco Vega | Valencia | (null) | (null) | (null) |
23 rows. Compare with 03-02:
| Query | Rows | Composition |
|---|---|---|
customers INNER JOIN orders |
20 | Real orders only |
customers LEFT JOIN orders |
23 | 20 orders + 3 customers with no orders |
There they are: Núria, Hugo and Inés, with the whole orders part at NULL. The arithmetic is exact and worth internalising: the result of a LEFT JOIN has as many rows as the INNER JOIN plus one row per orphan left-hand row.
4.2. Every product with its sales lines
Same pattern, now over the catalogue. We trim it down to five products so the contrast is clear:
SELECT p.id AS product_id,
p.name AS product,
ol.id AS line_id,
ol.order_id,
ol.quantity
FROM products AS p
LEFT JOIN order_lines AS ol ON ol.product_id = p.id
WHERE p.id IN (12, 13, 18, 19, 20)
ORDER BY p.id, ol.id;| product_id | product | line_id | order_id | quantity |
|---|---|---|---|---|
| 12 | Reusable cotton bags (pack of 5) | 13 | 5 | 1 |
| 12 | Reusable cotton bags (pack of 5) | 31 | 13 | 2 |
| 12 | Reusable cotton bags (pack of 5) | 40 | 16 | 1 |
| 13 | Soy wax candles (pack of 2) | (null) | (null) | (null) |
| 18 | Bamboo toothbrush | 23 | 9 | 4 |
| 18 | Bamboo toothbrush | 32 | 13 | 3 |
| 18 | Bamboo toothbrush | 47 | 20 | 2 |
| 19 | Natural stick deodorant 50 g | (null) | (null) | (null) |
| 20 | Spirulina capsules 120 units | (null) | (null) | (null) |
Products 12 and 18 have been sold three times each and take up three rows. Products 13, 19 and 20 have never been sold and take up one row with everything at NULL.
Without the trimming WHERE, the complete query returns 50 rows: the 47 from order_lines plus the 3 from the never-sold products.
4.3. Every order with its sales rep
The third case is different from the previous two, and the nuance is worth noticing. Here it isn't the child row that's missing: the foreign key is NULL.
SELECT o.id AS order_id,
o.order_date,
o.status,
e.name || ' ' || e.last_name AS sales_rep
FROM orders AS o
LEFT JOIN employees AS e ON o.employee_id = e.id
ORDER BY o.id;| order_id | order_date | status | sales_rep |
|---|---|---|---|
| 1 | 2025-03-04 | delivered | (null) |
| 2 | 2025-03-12 | delivered | Óscar Peris Blasco |
| 3 | 2025-04-02 | delivered | (null) |
| 4 | 2025-04-19 | delivered | Laia Puig Sanchis |
| 5 | 2025-05-07 | delivered | (null) |
| 6 | 2025-05-23 | cancelled | Óscar Peris Blasco |
| 7 | 2025-06-11 | delivered | (null) |
| 8 | 2025-06-28 | delivered | Laia Puig Sanchis |
| 9 | 2025-07-15 | delivered | (null) |
| 10 | 2025-08-03 | delivered | Óscar Peris Blasco |
| 11 | 2025-09-09 | delivered | (null) |
| 12 | 2025-10-01 | delivered | Laia Puig Sanchis |
| 13 | 2025-10-22 | delivered | (null) |
| 14 | 2025-11-14 | delivered | Marc Estévez Roig |
| 15 | 2025-12-02 | delivered | (null) |
| 16 | 2025-12-19 | shipped | Óscar Peris Blasco |
| 17 | 2026-01-13 | shipped | (null) |
| 18 | 2026-01-27 | paid | Laia Puig Sanchis |
| 19 | 2026-02-09 | paid | (null) |
| 20 | 2026-02-21 | pending | Marc Estévez Roig |
20 rows: all 20 orders. Against the 10 the INNER JOIN of 03-02 returned. The NULLs in the sales_rep column mean something perfectly legible for the business here: order placed through the web, with no sales rep assigned. Ten out of twenty, exactly the proportion 01-06 described.
Notice one detail: sales_rep is NULL on two counts. First because o.employee_id is NULL and matches nothing; and second because, even if it did match, the concatenation e.name || ' ' || e.last_name over null columns gives NULL (the trap from 02-02).
Summary of the three cases
| Case | Left | Right | Rows | What it recovers |
|---|---|---|---|---|
| Customers and orders | customers (15) |
orders |
23 | Customers 13, 14, 15 |
| Products and sales | products (20) |
order_lines |
50 | Products 13, 19, 20 |
| Orders and sales rep | orders (20) |
employees |
20 | The 10 web orders |
- The anti-join pattern: finding what doesn't match
So far we've used the LEFT JOIN to keep what doesn't match. Now we're going to use it to keep only that, which is one of the most requested queries in any company: inactive customers, products with no turnover, unpaid invoices, unverified users.
The technique is called an anti-join and it's written in two moves:
- A
LEFT JOINthat keeps everything from the left. - A
WHERE <right table's primary key> IS NULLthat keeps only the rows that found no partner.
flowchart LR
A["LEFT JOIN<br/>every left row"] --> B["rows with a partner<br/>o.id has a value"]
A --> C["rows with no partner<br/>o.id IS NULL"]
B --> D["❌ discarded by the WHERE"]
C --> E["✅ the result we are after"]
5.1. Which customers have never bought
SELECT c.id,
c.name,
c.last_name,
c.email,
c.city,
c.signup_date
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
WHERE o.id IS NULL
ORDER BY c.id;| id | name | last_name | city | signup_date | |
|---|---|---|---|---|---|
| 13 | Núria | Bosch Ferrer | nuria.bosch@example.com | Barcelona | 2025-06-20 |
| 14 | Hugo | Iglesias Pardo | hugo.iglesias@example.com | Zaragoza | 2025-09-12 |
| 15 | Inés | Carrasco Vega | ines.carrasco@example.com | Valencia | 2026-01-08 |
Three rows. This is exactly the list the marketing department would ask for to launch a first-purchase campaign.
5.2. Which products have never been sold
SELECT p.id,
p.name,
p.price,
p.stock,
p.active
FROM products AS p
LEFT JOIN order_lines AS ol ON ol.product_id = p.id
WHERE ol.id IS NULL
ORDER BY p.id;| id | name | price | stock | active |
|---|---|---|---|---|
| 13 | Soy wax candles (pack of 2) | 13.75 | 0 | true |
| 19 | Natural stick deodorant 50 g | 7.80 | 75 | true |
| 20 | Spirulina capsules 120 units | 16.40 | 55 | false |
Three rows, and with a different diagnosis for each: the candles don't sell because there's no stock; the spirulina because it's been discontinued (active = false); the deodorant has stock and is active, so its problem is commercial. A well-framed JOIN doesn't just return data: it points at the cause.
5.3. The three rules of the anti-join
| Rule | Why |
|---|---|
The JOIN must be a LEFT (or RIGHT), never an INNER |
An INNER JOIN has already discarded the partnerless rows: WHERE ... IS NULL would always return 0 rows |
The IS NULL condition goes in the WHERE, never in the ON |
In the ON it would be part of the matching and wouldn't filter anything useful |
The column you check must be NOT NULL in the right table |
If you picked a nullable column, you'd confuse "no partner" with "partner with an empty value". Always use its primary key |
About IS NULL: it's the correct operator for asking about nulls, because = NULL is never true (you saw it in 02-03 with employee_id = NULL returning 0 rows). Its complete treatment, together with IS NOT NULL and COALESCE, is lesson 04-03.
Note: in module 7 you'll see two other ways of writing the same thing:
NOT EXISTSwith a correlated subquery, andNOT IN. All three have the same goal and different behaviour around nulls. The anti-join withLEFT JOINis the one you can write today, and it's perfectly idiomatic.
- The trap: a condition in
ON versus a condition in WHERE
ON versus a condition in WHEREHere's the most important content of the lesson. Pay attention to the question, because the key is in it:
"Give me every customer, with their delivered orders."
Notice the "every": the report must list all 15 customers, whether or not they have delivered orders. Let's write it both possible ways.
Version A: the condition in the ON
-- ✅ CORRECT for the question as posed
SELECT c.id AS customer_id,
c.name || ' ' || c.last_name AS customer,
o.id AS order_id,
o.order_date,
o.status
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.id
AND o.status = 'delivered'
ORDER BY c.id, o.id;| customer_id | customer | order_id | order_date | status |
|---|---|---|---|---|
| 1 | Lucía Martínez Soler | 1 | 2025-03-04 | delivered |
| 1 | Lucía Martínez Soler | 5 | 2025-05-07 | delivered |
| 1 | Lucía Martínez Soler | 15 | 2025-12-02 | delivered |
| 2 | Carlos Ferrer Ibáñez | 2 | 2025-03-12 | delivered |
| 2 | Carlos Ferrer Ibáñez | 11 | 2025-09-09 | delivered |
| 3 | Marta Sanchis Gil | 3 | 2025-04-02 | delivered |
| 4 | Javier Ortega Ruiz | 4 | 2025-04-19 | delivered |
| 5 | Ana Belmonte Roca | (null) | (null) | (null) |
| 6 | Pau Llorens Vidal | 7 | 2025-06-11 | delivered |
| 7 | Sofia Moreira Costa | 8 | 2025-06-28 | delivered |
| 8 | Tiago Almeida Nunes | 9 | 2025-07-15 | delivered |
| 9 | Camille Dubois | 10 | 2025-08-03 | delivered |
| 10 | Julien Moreau | 12 | 2025-10-01 | delivered |
| 11 | Elena Navarro Puig | 13 | 2025-10-22 | delivered |
| 12 | Diego Ramos Herrera | 14 | 2025-11-14 | delivered |
| 13 | Núria Bosch Ferrer | (null) | (null) | (null) |
| 14 | Hugo Iglesias Pardo | (null) | (null) | (null) |
| 15 | Inés Carrasco Vega | (null) | (null) | (null) |
18 rows and all 15 customers present. The ones with no delivered order appear with NULL: Núria, Hugo and Inés because they've never bought, and Ana Belmonte Roca because her two orders are cancelled and paid, neither of them delivered. Ana is the most interesting case: she exists in orders, but none of her orders passes the ON condition.
Version B: the condition in the WHERE
We change exactly two words: AND becomes WHERE.
-- ⚠️ INCORRECT for the question as posed
SELECT c.id AS customer_id,
c.name || ' ' || c.last_name AS customer,
o.id AS order_id,
o.order_date,
o.status
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
WHERE o.status = 'delivered'
ORDER BY c.id, o.id;| customer_id | customer | order_id | order_date | status |
|---|---|---|---|---|
| 1 | Lucía Martínez Soler | 1 | 2025-03-04 | delivered |
| 1 | Lucía Martínez Soler | 5 | 2025-05-07 | delivered |
| 1 | Lucía Martínez Soler | 15 | 2025-12-02 | delivered |
| 2 | Carlos Ferrer Ibáñez | 2 | 2025-03-12 | delivered |
| 2 | Carlos Ferrer Ibáñez | 11 | 2025-09-09 | delivered |
| 3 | Marta Sanchis Gil | 3 | 2025-04-02 | delivered |
| 4 | Javier Ortega Ruiz | 4 | 2025-04-19 | delivered |
| 6 | Pau Llorens Vidal | 7 | 2025-06-11 | delivered |
| 7 | Sofia Moreira Costa | 8 | 2025-06-28 | delivered |
| 8 | Tiago Almeida Nunes | 9 | 2025-07-15 | delivered |
| 9 | Camille Dubois | 10 | 2025-08-03 | delivered |
| 10 | Julien Moreau | 12 | 2025-10-01 | delivered |
| 11 | Elena Navarro Puig | 13 | 2025-10-22 | delivered |
| 12 | Diego Ramos Herrera | 14 | 2025-11-14 | delivered |
14 rows and only 12 customers. Ana, Núria, Hugo and Inés have disappeared. The LEFT JOIN has behaved like an INNER JOIN.
Why it happens
Go back to the logical order from 03-01 and follow Núria's row through each version:
flowchart TD
subgraph A["Version A · condition in ON"]
A1["FROM: cartesian"] --> A2["ON: customer_id = 13<br/>AND status = 'delivered'<br/>→ no partner"]
A2 --> A3["1d: Núria is added<br/>with orders at NULL"]
A3 --> A4["WHERE: there is none<br/>→ ✅ Núria survives"]
end
subgraph B["Version B · condition in WHERE"]
B1["FROM: cartesian"] --> B2["ON: customer_id = 13<br/>→ no partner"]
B2 --> B3["1d: Núria is added<br/>with orders at NULL"]
B3 --> B4["WHERE: NULL = 'delivered'<br/>is not TRUE<br/>→ ❌ Núria is removed"]
end
Step 1d does add Núria in both versions. The difference comes afterwards: in version B, the WHERE evaluates o.status = 'delivered' over a row whose o.status is NULL. And NULL = 'delivered' isn't FALSE, it's NULL, which isn't TRUE either, so the row is discarded. It's the same mechanism from 02-03 acting in a new place.
The rule to burn into your memory: in a
LEFT JOIN, any condition in theWHEREover a column of the right table turns theLEFTinto anINNER, because the rows padded withNULLcan't satisfy it.
Decision table
| Where to put the condition on the right table | Effect | When you want it |
|---|---|---|
In the ON |
Restricts what gets matched. The partnerless left rows are kept with NULL |
"Every X, with their Y that meet Z" |
In the WHERE |
Filters the final result. The LEFT JOIN degrades to an INNER JOIN |
"Only the X that have some Y meeting Z" |
In the WHERE, with IS NULL |
Keeps only the partnerless rows | Anti-join: "the X that have no Y at all" |
All three are valid: each answers a different question. The mistake isn't using the WHERE, it's using it believing it does what the ON does.
One case that is safe: a condition in the WHERE over a column of the left table degrades nothing. WHERE c.country = 'Spain' filters customers, and the ones that pass the filter keep their NULLs on the right without any trouble. The danger is exclusively with right-hand columns.
- Chained
LEFT JOINs
LEFT JOINsWith three or more tables the LEFT JOIN keeps its logic, but a surprising rule shows up: an INNER JOIN placed after a LEFT JOIN cancels the LEFT's effect.
We want "every customer, with their orders and the sales rep of each order". Written with LEFT on both hops:
-- ✅ CORRECT: 23 rows, all 15 customers
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
LEFT JOIN employees AS e ON o.employee_id = e.idAnd now the same path with an INNER JOIN on the second hop:
-- ⚠️ INCORRECT: 10 rows
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
INNER JOIN employees AS e ON o.employee_id = e.id| Query | Rows | What's left |
|---|---|---|
LEFT + LEFT |
23 | The 15 customers, the 20 orders, with NULL where something's missing |
LEFT + INNER |
10 | Only the 10 orders that have a sales rep |
The reasoning: JOINs are resolved left to right, so (customers LEFT JOIN orders) produces 23 rows; over those 23, the INNER JOIN with employees demands that o.employee_id match an employee. The 3 rows of customers with no orders have o.employee_id = NULL and fall away; the 10 rows of web orders have employee_id = NULL and fall away too. Ten remain.
Practical rule: once you've opened a chain with a
LEFT JOIN, every subsequent hop on that branch must be aLEFT JOIN. A singleINNERin the middle undoes all the work, and it does so without any warning.
It's a very easy mistake to make when extending an existing query: somebody adds JOIN products ON ... at the end of a query that started with a LEFT JOIN, and the report loses rows overnight without anybody touching the LEFT.
- When the right table has several rows per left-hand one
The LEFT JOIN doesn't protect you from 03-02's row multiplication. There's still one row per pairing:
SELECT p.id AS product_id,
p.name AS product,
r.id AS review_id,
r.rating
FROM products AS p
LEFT JOIN reviews AS r ON r.product_id = p.id
WHERE p.id IN (1, 2, 4, 6)
ORDER BY p.id, r.id;| product_id | product | review_id | rating |
|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 1 | 5 |
| 1 | Extra virgin olive oil 500 ml | 7 | 5 |
| 2 | Organic brown rice 1 kg | 2 | 4 |
| 2 | Organic brown rice 1 kg | 10 | 5 |
| 4 | Spelt pasta 500 g | (null) | (null) |
| 6 | Aloe vera face cream 50 ml | 3 | 5 |
| 6 | Aloe vera face cream 50 ml | 9 | 4 |
Four products produce seven rows: two reviews for products 1, 2 and 6, and one empty row for number 4. Over the whole catalogue, products LEFT JOIN reviews returns 23 rows: the 12 reviews plus the 11 products with none.
The general arithmetic of a LEFT JOIN is always this:
| Example | Match | Orphans | Total |
|---|---|---|---|
customers LEFT JOIN orders |
20 | 3 | 23 |
products LEFT JOIN order_lines |
47 | 3 | 50 |
products LEFT JOIN reviews |
12 | 11 | 23 |
orders LEFT JOIN employees |
10 | 10 | 20 |
And once again module 4's warning: if over the first case you summed c.signup_date, or counted customers, you'd get inflated figures, because Lucía appears three times. A LEFT JOIN keeps rows; it doesn't deduplicate them.
Common Mistakes and Tips
- Putting a condition on the right table in the
WHERE. It degrades theLEFT JOINto anINNER JOINwithout saying a word. It's the mistake of this lesson: 18 rows against 14, and four customers gone. - Slipping an
INNER JOINin after aLEFT JOIN. The same effect, in a chain: 23 rows become 10. - Writing the tables the wrong way round.
orders LEFT JOIN customersisn'tcustomers LEFT JOIN orders. TheLEFT JOINisn't commutative; the left is the one kept in full. - Doing the anti-join against a nullable column.
WHERE o.employee_id IS NULLdoesn't tell "there was no partner" from "there was a partner with an empty value". Always compare against the right side's primary key. - Trying an anti-join with an
INNER JOIN.INNER JOIN ... WHERE right.id IS NULLalways returns zero rows: theINNERhas already thrown those rows away. - Using
= NULLinstead ofIS NULL.WHERE o.id = NULLalways returns zero rows (02-03). - Assuming
NULLin the result means zero.o.shipping_costisNULL, not0.00, for a customer with no orders. Operating on it propagates the null (COALESCE, in 06-04). - Forgetting that the
LEFT JOINstill multiplies rows. It keeps the orphans, but it doesn't stop a customer with three orders taking up three rows. - Tip: write the
INNER JOINfirst, check the count, and then change it toLEFT. The difference between the two counts tells you exactly how many orphans there are. - Tip: for the anti-join, say the question out loud. "Customers with no orders", "products never sold", "invoices not paid". The "no" and the "never" are the signal that it's time for
LEFT JOIN ... WHERE ... IS NULL. - Tip: be suspicious of any
WHEREthat names the right table of aLEFT JOIN. Unless it's a deliberateIS NULL, it should almost always be in theON.
Exercises
Exercise 1
The product team wants to know which catalogue references have received no review at all, so they can launch a campaign asking for opinions. Write the query that returns the id, name, category and price of those products, sorted by id.
Then answer: why can the JOIN with categories be an INNER JOIN without breaking the anti-join?
Exercise 2
Logistics needs the complete listing of the 20 orders with their return information, if there was one: order id, date, status, shipping cost and —when a return exists— its reason and amount.
- Write the query.
- Then write the variant that returns only the orders that had no return, and state how many rows it gives.
Exercise 3
A colleague hands you this query and says: "I want every customer with their orders paid by card, but I'm missing customers".
SELECT c.id, c.name, o.id AS order_id, o.payment_method
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
WHERE o.payment_method = 'card';- Explain what that query is really doing.
- Fix it so that it returns what he wants.
- Predict how many rows each version returns and how many distinct customers appear in each.
Solutions
Solution 1
SELECT p.id,
p.name AS product,
cat.name AS category,
p.price
FROM products AS p
INNER JOIN categories AS cat ON p.category_id = cat.id
LEFT JOIN reviews AS r ON r.product_id = p.id
WHERE r.id IS NULL
ORDER BY p.id;| id | product | category | price |
|---|---|---|---|
| 3 | Raw orange blossom honey 500 g | Food | 9.75 |
| 4 | Spelt pasta 500 g | Food | 2.80 |
| 7 | Rosemary solid shampoo 80 g | Natural cosmetics | 8.40 |
| 8 | Almond body oil 200 ml | Natural cosmetics | 14.25 |
| 9 | Calendula lip balm 15 ml | Natural cosmetics | 4.60 |
| 11 | Loofah scrubber (pack of 3) | Sustainable home | 5.50 |
| 13 | Soy wax candles (pack of 2) | Sustainable home | 13.75 |
| 14 | Organic chamomile tea 20 bags | Drinks | 3.25 |
| 17 | Cold-pressed orange juice 1 L | Drinks | 5.40 |
| 19 | Natural stick deodorant 50 g | Personal hygiene | 7.80 |
| 20 | Spirulina capsules 120 units | Supplements | 16.40 |
11 rows, exactly the 11 products with no review that 01-06 announced. Only 9 of the 20 products have ever been reviewed.
Why the JOIN with categories can be an INNER: because section 7 warns about INNER JOINs that appear after a LEFT JOIN on the same branch. That isn't the case here: categories hangs off products through an FK pointing at a category that always exists, so that INNER JOIN discards no product. The 20 starting rows are still 20 before the LEFT JOIN with reviews is applied. The precise rule is: an INNER JOIN is safe as long as it can't remove rows from the protagonist table.
(If category_id allowed NULL with genuinely null data, that INNER JOIN would indeed lose products and it would have to be written as a LEFT JOIN too.)
Solution 2
1. Every order with its return, if there was one:
SELECT o.id AS order_id,
o.order_date,
o.status,
o.shipping_cost,
rt.reason,
rt.amount AS refunded_amount
FROM orders AS o
LEFT JOIN returns AS rt ON rt.order_id = o.id
ORDER BY o.id;It returns 20 rows: the 3 with a return (orders 6, 10 and 13) show a reason and an amount; the other 17 show *(null)* in those two columns.
2. Only the orders with no return (anti-join):
SELECT o.id AS order_id,
o.order_date,
o.status,
o.shipping_cost
FROM orders AS o
LEFT JOIN returns AS rt ON rt.order_id = o.id
WHERE rt.id IS NULL
ORDER BY o.id;| order_id | order_date | status | shipping_cost |
|---|---|---|---|
| 1 | 2025-03-04 | delivered | 4.95 |
| 2 | 2025-03-12 | delivered | 0.00 |
| 3 | 2025-04-02 | delivered | 4.95 |
| 4 | 2025-04-19 | delivered | 4.95 |
| 5 | 2025-05-07 | delivered | 0.00 |
| 7 | 2025-06-11 | delivered | 6.50 |
| 8 | 2025-06-28 | delivered | 9.90 |
| 9 | 2025-07-15 | delivered | 9.90 |
| 11 | 2025-09-09 | delivered | 0.00 |
| 12 | 2025-10-01 | delivered | 12.50 |
| 14 | 2025-11-14 | delivered | 4.95 |
| 15 | 2025-12-02 | delivered | 0.00 |
| 16 | 2025-12-19 | shipped | 4.95 |
| 17 | 2026-01-13 | shipped | 9.90 |
| 18 | 2026-01-27 | paid | 4.95 |
| 19 | 2026-02-09 | paid | 4.95 |
| 20 | 2026-02-21 | pending | 12.50 |
17 rows = 20 orders − 3 returns. Exactly orders 6, 10 and 13 are missing.
Solution 3
1. What it's really doing. The condition o.payment_method = 'card' is in the WHERE and applies to a column of the right table. Every customer row with no order reaches the WHERE with o.payment_method at NULL, and NULL = 'card' isn't TRUE. Result: the LEFT JOIN degrades to an INNER JOIN and the query returns only the customers who have at least one order paid by card. It's a legitimate question, but not the one he wanted.
2. The fix: move the condition into the ON.
-- ✅ CORRECT
SELECT c.id,
c.name,
o.id AS order_id,
o.payment_method
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.id
AND o.payment_method = 'card'
ORDER BY c.id, o.id;3. Row prediction. The orders with payment_method = 'card' are ids 1, 3, 5, 6, 8, 10, 11, 13, 15, 16 and 19: 11 orders, from 9 distinct customers (Lucía appears three times).
| Version | Rows | Distinct customers |
|---|---|---|
Original (WHERE) |
11 | 9 — only those who paid by card at some point |
Fixed (ON) |
17 | 15 — the 11 pairings + the 6 customers with no card purchase at all, with NULL |
The 6 customers who appear with NULL in the fixed version are: Tiago (8), Julien (10), Diego (12), Núria (13), Hugo (14) and Inés (15). The last three because they've never bought; the first three because they did buy, but paying by PayPal or transfer.
Conclusion
The LEFT JOIN is, in practice, the JOIN that solves the most business problems:
- It keeps every row of the left table, matching or not, padding the right-hand columns with
NULL.LEFT JOINandLEFT OUTER JOINare the same thing. - The
NULLs are manufactured by the engine as it builds the result; they weren't in the data. That's why you can detect the absence by looking at the right table's primary key. - You've recovered GreenStore's three gaps: customers 13, 14 and 15 (23 rows instead of 20), products 13, 19 and 20 (50 rows instead of 47) and the 10 web orders (20 rows instead of 10).
- The anti-join pattern
LEFT JOIN ... WHERE right.id IS NULLanswers the questions with "no" and "never" in them: three customers who haven't bought, three products nobody has sold, eleven products with no reviews. - You know that a condition on the right table in the
WHEREdegrades theLEFT JOINto anINNER JOIN: 18 rows and 15 customers against 14 rows and 12 customers, with the same question written two ways. If you want "every X with their Y meeting Z", the condition goes in theON. - An
INNER JOINchained after aLEFT JOINcancels its effect: 23 rows drop to 10. Once you've opened a branch withLEFT, carry on withLEFT. - The
LEFT JOINdoesn't deduplicate: it still multiplies rows when the right side has several per left-hand one.
In the next lesson, RIGHT JOIN, we'll see the exact mirror image of what you've just learned. You'll check that A RIGHT JOIN B and B LEFT JOIN A return exactly the same thing, you'll understand why most style guides prefer the LEFT in spite of that, and you'll finally meet Irene Salvador Mira and Daniel Vercher Lluch, the two employees who have never handled an order.
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
