“Customers who have placed at least one order” — three equivalent forms, all standard SQL:
-- EXISTS (correlated subquery — stops at the first match)
SELECT * FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
-- IN (subquery)
SELECT * FROM customers c
WHERE c.id IN (SELECT o.customer_id FROM orders o);
-- JOIN + DISTINCT — only reach for this if you're also selecting order columns;
-- otherwise a match with 5 orders duplicates the customer row 5 times before DISTINCT collapses it
SELECT DISTINCT c.*
FROM customers c
JOIN orders o ON o.customer_id = c.id;
Negate it — “customers with no orders” — and one of the three breaks silently:
-- NOT EXISTS — correct regardless of NULLs in orders.customer_id
SELECT * FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
-- NOT IN — returns ZERO rows if the subquery contains even one NULL customer_id
SELECT * FROM customers c
WHERE c.id NOT IN (SELECT o.customer_id FROM orders o);
-- Fix, if you must use NOT IN: filter the NULL out of the subquery explicitly
SELECT * FROM customers c
WHERE c.id NOT IN (SELECT o.customer_id FROM orders o WHERE o.customer_id IS NOT NULL);
The
NOT IN+NULLtrap is identical in all six dialects — it’s three-valued logic, not an engine quirk.x NOT IN (1, 2, NULL)expands tox <> 1 AND x <> 2 AND x <> NULL. Comparing anything toNULLyieldsUNKNOWN, andUNKNOWN AND ...can never becomeTRUE— so every row is excluded, even ones that clearly have no match.NOT EXISTSnever has this problem because it doesn’t compare values to the subquery’s rows at all, it just asks whether any row satisfies the join condition. TheEXISTS/IN/JOINsyntax itself is the same across MySQL, PostgreSQL, SQLite, BigQuery, Snowflake and DuckDB, and every one of their query optimizers rewrites a correctEXISTS/IN/NOT EXISTSinto a semi-join or anti-join internally — so once theNULLtrap is handled, reach for whichever form reads clearest, not whichever you assume is “faster.”