Keep the rows on the left that never found a partner on the right: the customers who never ordered.
An inner join answers who matched. An anti-join answers the opposite question: who didn't. You walk each row of table A, look for any partner in table B, and keep only the rows that came back empty-handed.
Two ways of writing it say this faithfully: NOT EXISTS, and a LEFT JOIN whose padded NULLs you filter for. A third way, NOT IN, looks equivalent and usually behaves, until a single NULL appears in the list and the whole result quietly collapses to nothing.
Ready. We are looking for customers with no matching row in orders.
All three queries below ask the same English question: which customers have no order? The first two ask it in a way three-valued logic cannot distort. The third asks it as “is this id absent from that list of values”, and a NULL in the list makes “absent” unprovable.
-- 1. NOT EXISTS — says exactly what we mean, and is NULL-safe
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.id -- the correlation is the whole point
);
-- 2. LEFT JOIN ... IS NULL — same answer, different shape
SELECT c.id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.customer_id IS NULL; -- test the join key (or B's primary key):
-- a column that is never NULL in a matched row
-- 3. NOT IN — reads the same, behaves differently
SELECT c.id, c.name
FROM customers c
WHERE c.id NOT IN (SELECT o.customer_id FROM orders o);
-- if orders.customer_id is nullable, this returns ZERO rows the moment
-- one NULL is present, because id <> NULL is UNKNOWN, never TRUE.
-- Repair (if you must keep NOT IN):
-- ... NOT IN (SELECT o.customer_id FROM orders o WHERE o.customer_id IS NOT NULL)
Why: x NOT IN (a, b, NULL) expands to x <> a AND x <> b AND x <> NULL. The last conjunct is UNKNOWN. TRUE AND TRUE AND UNKNOWN = UNKNOWN, and WHERE keeps only rows that evaluate to TRUE. Rows that do match still evaluate to FALSE and are dropped, so every row is dropped.
| plan the engine may choose | time | extra space | when |
|---|---|---|---|
| hash anti-join | O(|A| + |B|) | O(|B|) | large unsorted inputs; build side must fit in memory |
| merge anti-join | O(|A| + |B|) | O(1) | both inputs already ordered by the key (index scan); otherwise add O(n log n) to sort |
| indexed nested loop | O(|A| · log|B|) | O(1) | A is small and B has an index on the join key |
| plain nested loop | O(|A| · |B|) | O(1) | no index: the plan you don't want |
The trade-off: NOT EXISTS and LEFT JOIN … IS NULL normally compile to the same anti-join operator, so pick whichever reads better. NOT IN over a nullable column forces the engine to preserve three-valued semantics, which often blocks the anti-join rewrite: slower as well as wrong.
NOT EXISTS, or add WHERE col IS NOT NULL inside the subquery. “The report is empty” is a very quiet bug.LEFT JOIN orders o … WHERE o.shipped_at IS NULL is not an anti-join: it finds customers whose latest matching order has no ship date, plus the unmatched ones. Test the join key or B's primary key, which can only be NULL when nothing matched.LEFT JOIN orders o ON o.customer_id = c.id WHERE o.status = 'paid' AND o.id IS NULL is a contradiction and returns nothing. Filters on the right-hand table belong in the ON clause: ON o.customer_id = c.id AND o.status = 'paid', then WHERE o.id IS NULL.WHERE o.customer_id = c.id line and the subquery just asks “does any order exist?” True for every customer, so the result is empty. Same trap with a typo like o.customer_id = o.customer_id.id is NULL matches nothing, so an anti-join always reports it. Correct by the rules, surprising in a report: decide deliberately whether to exclude it.You need a win-back list: every customer with no order in the last 90 days. Write it as SELECT c.id, c.email FROM customers c WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id AND o.placed_at >= now() - interval '90 days'). Note the date test sits inside the subquery: it narrows what counts as a partner, so a customer whose only order is two years old still has no partner and stays in the list.
The tempting alternative, c.id NOT IN (SELECT customer_id FROM orders WHERE placed_at >= now() - interval '90 days'), reads almost identically. But if orders.customer_id is nullable, say guest checkouts write NULL there, a single recent guest order puts a NULL in that list, every comparison becomes UNKNOWN, and your win-back campaign mails nobody. Same intent, same data, empty result. The animation above is exactly that moment.
orders.customer_id is nullable and exactly one row holds NULL. What does WHERE c.id NOT IN (SELECT customer_id FROM orders) return?customers c and orders o?