What are the ways to find rows in one table that have no matching row in another?
There are four, and they are not equally safe. The LEFT JOIN with a NULL check finds rows where the outer join produced no partner. NOT EXISTS asks the same question with a correlated subquery. NOT IN is a set-membership test with a well-known NULL trap. EXCEPT is the set-difference operator and is the least flexible of the four.
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name VARCHAR(120) NOT NULL
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
amount DECIMAL(10, 2) NOT NULL
);
INSERT INTO customers (id, name) VALUES
(1, 'Ada Lovelace'),
(2, 'Grace Hopper'),
(3, 'Alan Turing'),
(4, 'Katherine Johnson');
INSERT INTO orders (id, customer_id, amount) VALUES
(1, 1, 120.00),
(2, 1, 340.50),
(3, 2, 75.25);
-- Anti-join with LEFT JOIN.
SELECT c.id, c.name
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
WHERE o.id IS NULL
ORDER BY c.id;
-- Anti-join with NOT EXISTS.
SELECT c.id, c.name
FROM customers AS c
WHERE NOT EXISTS (
SELECT 1 FROM orders AS o WHERE o.customer_id = c.id
)
ORDER BY c.id;Both queries return the same two rows: Alan Turing and Katherine Johnson, the customers with no orders.
The LEFT JOIN version keeps every left row and then discards the ones where the join failed. The critical detail is that the NULL check has to be on a column from the right-hand table, and it has to use IS NULL rather than an equality against a sentinel value — and it must not be combined with a WHERE predicate on that same right-hand column, because any such predicate turns the outer join back into an inner one.
NOT EXISTS states the same requirement directly and is the safer default, because EXISTS only ever returns TRUE or FALSE. It also reads better when the subquery needs a different correlation or an extra condition.
NOT IN is where the trap lives. If the subquery can return a NULL — and it can, whenever the joined column is nullable — then NOT IN evaluates to UNKNOWN for every row, and the query returns nothing at all. The fix is to make the subquery column NOT NULL, or to use NOT EXISTS instead. EXCEPT works but requires the two branches to have identical column lists, which makes it awkward for anything beyond a simple id difference.