Why Your AI's LEFT JOIN Quietly Becomes an INNER JOIN
Asked for 'all customers and their orders, including customers with none' — got back only customers who have orders. A LEFT JOIN with a filter on the right-hand table sitting in WHERE instead of ON silently defeats the whole point of the LEFT JOIN.
You ask for "all customers, and their most recent order if they have one." The result only shows customers who do have an order. The ones with zero orders — which was the entire point of asking for "if they have one" — are just gone, with no error and a query that reads like it should obviously work.
This is one of the most common LEFT JOIN mistakes, and it happens because of where a filter condition is placed, not because LEFT JOIN itself is broken.
What LEFT JOIN promises, and how WHERE breaks that promise
LEFT JOIN is supposed to keep every row from the left (first) table, whether or not it finds a match on the right. For a customer with no orders, the right-hand columns (order_id, status, amount) all come back NULL — that row is still there, just with nothing on the order side.
The problem starts when a filter on one of those right-hand columns lands in WHERE:
SELECT c.name, o.status
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid'
For a customer with zero orders, o.status is NULL. WHERE o.status = 'paid' evaluates NULL = 'paid', which is neither true nor false — the same three-valued-logic mechanic behind NULL rows disappearing from a plain filter — so the row gets dropped by WHERE, same as if it had failed any other condition. Every customer with no orders vanishes, and the query silently behaves exactly like an INNER JOIN would have, even though it's written as LEFT JOIN.
Why this is easy to write without noticing
"Customers and their paid orders" reads naturally as one filter — of course you'd only want paid ones. Nothing about writing the condition in WHERE looks wrong, and if you're mentally modeling "join the tables, then filter," WHERE is exactly where a filter belongs. The part that's counterintuitive is that this specific filter, on this specific side of the join, needs to happen as part of the join itself, not after.
The fix: move the condition into ON
SELECT c.name, o.status
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid'
Now status = 'paid' is part of deciding what counts as a match, not a filter applied after matching. A customer with no paid orders (maybe none at all, maybe only unpaid ones) still gets a row — with o.status as NULL — instead of disappearing entirely. If you specifically want to filter the base table (customers created this year, say), that's fine to leave in WHERE, since it's not touching the joined table's nullable columns and doesn't have this failure mode.
The one-line way to tell these two placements apart
- Condition in
ON→ "only match orders that are paid; keep the customer regardless" - Condition in
WHERE→ "keep only rows where a paid order matched; drop everyone else" — which quietly reintroducesINNER JOINbehavior
Same words, same table, same column — opposite result, depending purely on which clause the condition sits in.
How to catch it
Ask directly: "does this include customers with zero orders?" If the intended answer is yes, and the query has a WHERE clause referencing a column from the joined table, that's the first thing to check — especially if the actual row count looks suspiciously close to what an INNER JOIN would have returned.
The prompt that heads this off
When writing a LEFT JOIN, don't put filter conditions on the joined
table's columns in WHERE — that silently turns it into an INNER JOIN
by dropping every row with no match (their columns are NULL, and NULL
comparisons fail). Put those conditions in the ON clause instead, so
unmatched left-side rows are preserved.
The short version
LEFT JOIN keeps unmatched rows right up until a WHERE clause quietly un-keeps them. It's not a bug in LEFT JOIN — it's a filter landing on the wrong side of the join, and the fix is a two-word move from WHERE to ON.
Read-only MCP gateway: mcpserver.design.
Related reading: