Inner vs outer joins
INNER keeps only matched pairs; LEFT/RIGHT/FULL OUTER also keep unmatched rows, NULL-extended. The classic prod bug: a predicate on the outer table in WHERE silently demotes a LEFT JOIN to an INNER JOIN.
A “users who haven’t ordered yet” report quietly showed zero rows for three weeks. The query was a LEFT JOIN from users to orders — correct so far — but a WHERE o.status = 'cancelled' clause sat at the bottom. That one line turned the outer join back into an inner join and erased every unmatched user. The dashboard wasn’t broken; it was filtering on a column that is NULL for exactly the rows it was supposed to find. This is the most common join bug in production, and it hides in plain sight.
INNER keeps matches; OUTER keeps the unmatched too
From the last lesson: a join is a filtered product, and the question is what to do with rows that find no partner. That choice is the inner/outer distinction.
- INNER JOIN — keep only the surviving pairs. A user with no orders simply vanishes from the result.
- LEFT OUTER JOIN — keep every left row no matter what. If a left row finds a match, it’s paired normally; if it finds none, it is still emitted once, with every right-side column set to
NULL. This is NULL-extension. - RIGHT OUTER JOIN — the mirror image: keep every right row, NULL-extend the unmatched left. (
A RIGHT JOIN B≡B LEFT JOIN A; most teams normalize toLEFTfor readability.) - FULL OUTER JOIN — keep everything: matched pairs, unmatched left rows (right NULL-extended), and unmatched right rows (left NULL-extended).
-- Every user, plus their orders if any; users with none get NULL order columns
SELECT u.id, u.name, o.id AS order_id, o.total
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;A user who never ordered appears once here with order_id and total as NULL. That row is the entire reason to use an outer join — it carries the information “this left row had no partner.”
The trap: WHERE on the outer table demotes the join
Here is the bug from the hook, distilled. The placement of a predicate decides whether it filters the product (in ON) or the result (in WHERE) — and for an outer join those are not the same.
-- WRONG: this is secretly an INNER JOIN
SELECT u.id, u.name, o.id AS order_id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'shipped'; -- runs AFTER NULL-extensionFor a user with no orders, o.status is NULL. The comparison NULL = 'shipped' is UNKNOWN, which WHERE treats as false, so that NULL-extended row is discarded. Every unmatched left row is thrown away — the LEFT JOIN now behaves exactly like an INNER JOIN. The fix is to move the condition into ON, so it filters which orders count as a match before NULL-extension:
-- RIGHT: filter the match in ON, keep the outer semantics
SELECT u.id, u.name, o.id AS order_id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'shipped';Now users with no shipped order are still emitted, NULL-extended. The rule: a predicate on the outer (right) table belongs in ON, not WHERE. A WHERE on a NULLable outer column is almost always a demoted outer join. The one legitimate exception is WHERE o.id IS NULL, which deliberately keeps only the unmatched rows — that’s the anti-join pattern in the next lesson.
Reading and counting outer results
Because unmatched rows carry NULLs, you usually wrap outer columns in COALESCE for display or aggregation:
SELECT u.id,
COUNT(o.id) AS order_count, -- COUNT ignores NULLs → 0 for non-orderers
COALESCE(SUM(o.total), 0) AS lifetime_value -- SUM of no rows is NULL, not 0
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id;Two senior details hide here. COUNT(o.id) counts non-NULL values, so a user with no orders correctly gets 0 — but COUNT(*) would count the NULL-extended row as 1, a classic off-by-one. And SUM over zero matching rows returns NULL, not 0, so COALESCE(SUM(...), 0) is required for a sane total. To split matched from unmatched, count o.id IS NULL: that’s your “users who never ordered” number.
▸Common mistake
A subtler version of the trap: LEFT JOIN orders o ON o.user_id = u.id WHERE o.total > 100. People read it as “users and their orders over 100, including users with no such order.” It is not — it silently drops every user whose orders are all under 100 (and every user with no orders), because their NULL or filtered-out rows fail the WHERE. If you want “users, with their big orders attached when present,” the > 100 condition must live in ON. The mental test: would this predicate reject a NULL-extended row? If yes, and you wanted to keep that row, it belongs in ON.
You want every user plus their orders, including users with zero orders. Which clause keeps the zero-order users?
A LEFT JOIN from users to orders has WHERE o.status = 'paid' appended. What actually happens to users who never ordered?
Order the logical steps Postgres takes for SELECT ... FROM users u LEFT JOIN orders o ON o.user_id=u.id WHERE u.country='JP':
- 1 Form the conceptual product of users and orders
- 2 Apply the ON predicate to decide which pairs are matches
- 3 NULL-extend every left (user) row that found no matching order
- 4 Apply the WHERE filter (u.country = 'JP') to the resulting rows
- 5 Project the SELECT columns and return the rows
- 01What does NULL-extension mean, and which join produces it?
- 02Explain how WHERE o.status = 'x' silently turns a LEFT JOIN into an INNER JOIN.
- 03Why use COUNT(o.id) and COALESCE(SUM(o.total),0) when aggregating a LEFT JOIN by user?
The inner/outer distinction answers “what about rows with no partner.” INNER drops them; LEFT/RIGHT/FULL OUTER keep the unmatched rows and NULL-extend the missing side, so a user with no orders still appears once with NULL order columns — the row that carries “no match” information. The dominant production bug is predicate placement: a condition on the outer (right) table belongs in ON, because a WHERE runs after NULL-extension and rejects the NULL rows (NULL = 'x' is UNKNOWN → false), silently demoting the LEFT JOIN to an INNER JOIN. The mental test is “would this predicate reject a NULL-extended row I want to keep?” — if yes, it goes in ON. The legitimate WHERE o.id IS NULL is the exception: it deliberately keeps only unmatched rows, which is the anti-join you’ll meet next. When you aggregate outer results, use COUNT(o.id) (NULL-aware) and COALESCE(SUM(...),0) to handle the NULLs cleanly. Now when you see a LEFT JOIN followed by a WHERE on the right-side table, you’ll pause and ask: does this condition reject my unmatched rows? If yes, it belongs in ON.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.