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

  1. The LEFT JOIN rule and the set diagram
  2. LEFT JOIN = LEFT OUTER JOIN
  3. Where exactly the NULLs come from
  4. GreenStore's three real cases
  5. The anti-join pattern: finding what doesn't match
  6. The trap: a condition in ON versus a condition in WHERE
  7. Chained LEFT JOINs
  8. When the right table has several rows per left-hand one
  9. Common Mistakes and Tips
  10. Exercises
  11. Conclusion

  1. The LEFT JOIN rule and the set diagram

flowchart 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.id

From 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" → customers is the left. "I want every order, with its sales rep if there is one" → orders is the left.

  1. LEFT JOIN = LEFT OUTER JOIN

The 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.id

OUTER 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.

  1. Where exactly the NULLs come from

This 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:

  1. You can detect the absence by looking at any column on the right. If o.id IS NULL in a LEFT JOIN from customers, it means there was no partner. It's the basis of section 5's anti-join.
  2. You should look at a NOT NULL column 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 legitimately NULL.

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.

  1. 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

  1. 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:

  1. A LEFT JOIN that keeps everything from the left.
  2. A WHERE <right table's primary key> IS NULL that 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 email 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 EXISTS with a correlated subquery, and NOT IN. All three have the same goal and different behaviour around nulls. The anti-join with LEFT JOIN is the one you can write today, and it's perfectly idiomatic.

  1. The trap: a condition in ON versus a condition in WHERE

Here'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 the WHERE over a column of the right table turns the LEFT into an INNER, because the rows padded with NULL can'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.

  1. Chained LEFT JOINs

With 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.id

And 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 a LEFT JOIN. A single INNER in 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.

  1. 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:

result rows = rows that match (INNER) + orphan left rows
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 the LEFT JOIN to an INNER JOIN without saying a word. It's the mistake of this lesson: 18 rows against 14, and four customers gone.
  • Slipping an INNER JOIN in after a LEFT JOIN. The same effect, in a chain: 23 rows become 10.
  • Writing the tables the wrong way round. orders LEFT JOIN customers isn't customers LEFT JOIN orders. The LEFT JOIN isn't commutative; the left is the one kept in full.
  • Doing the anti-join against a nullable column. WHERE o.employee_id IS NULL doesn'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 NULL always returns zero rows: the INNER has already thrown those rows away.
  • Using = NULL instead of IS NULL. WHERE o.id = NULL always returns zero rows (02-03).
  • Assuming NULL in the result means zero. o.shipping_cost is NULL, not 0.00, for a customer with no orders. Operating on it propagates the null (COALESCE, in 06-04).
  • Forgetting that the LEFT JOIN still multiplies rows. It keeps the orphans, but it doesn't stop a customer with three orders taking up three rows.
  • Tip: write the INNER JOIN first, check the count, and then change it to LEFT. 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 WHERE that names the right table of a LEFT JOIN. Unless it's a deliberate IS NULL, it should almost always be in the ON.

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.

  1. Write the query.
  2. 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';
  1. Explain what that query is really doing.
  2. Fix it so that it returns what he wants.
  3. 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 JOIN and LEFT OUTER JOIN are 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 NULL answers 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 WHERE degrades the LEFT JOIN to an INNER 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 the ON.
  • An INNER JOIN chained after a LEFT JOIN cancels its effect: 23 rows drop to 10. Once you've opened a branch with LEFT, carry on with LEFT.
  • The LEFT JOIN doesn'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

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved