Anti-Joins: Finding Missing Matches
Find records that have no counterpart: the DE's referential-integrity check.
Referential integrity: find the orphans
Before you trust a source, you check its referential integrity: does every order point at a real customer? Does every order_item point at a real product? Rows that point at a nonexistent parent are orphans, and finding them is a DE's daily hygiene. The pattern is the anti-join: "give me every left row that has no match on the right."
The portable recipe is a LEFT JOIN plus an IS NULL filter:
SELECT o.order_id, o.customer_id
FROM orders AS o
LEFT JOIN customers AS c
ON o.customer_id = c.customer_id
WHERE c.customer_id IS NULL; -- keep ONLY the rows that failed to match
The LEFT JOIN keeps every order and NULL-pads the customer columns for unmatched orders. The WHERE c.customer_id IS NULL then keeps only those NULL-padded rows: the orphans. Every matched order is discarded because its c.customer_id is non-NULL.
| order_id | customer_id |
|---|---|
| 100 | 1 |
| 101 | 9 |
| 102 | 2 |
| 103 | 7 |
| customer_id |
|---|
| matched: 1 |
| 2 |
| 3 |
customer_id=1 matches → dropped (ANTI JOIN keeps only non-matches).
| orders.order_id | orders.customer_id | customers.customer_id |
|---|---|---|
| no rows yet | ||
Anatomy:
LEFT JOIN customers c ON ... → matched orders get c.*, orphans get NULLs
WHERE c.customer_id IS NULL → survives ONLY if there was NO match ← the anti-join
Two siblings, one distinction:
- Anti-join = rows with no match (what we just wrote).
- Semi-join = rows with a match, but you don't want the right table's columns, classically written
WHERE EXISTS (SELECT 1 FROM customers c WHERE c.customer_id = o.customer_id)orWHERE o.customer_id IN (SELECT customer_id FROM customers). Use it when you only need to confirm a match exists, not pull data from it.
In the warehouse this differs.
NOT INis a tempting shorthand for an anti-join, but it has a NULL landmine: if the subquery's list contains even one NULL,NOT INreturns no rows at all (three-valued logic:x NOT IN (…, NULL)is never true). TheLEFT JOIN … IS NULLandNOT EXISTSpatterns are NULL-safe and work identically across SQLite, Postgres, and every warehouse. Prefer them.
Keep it readable / common pitfall: the IS NULL must reference a column that is guaranteed non-NULL in matched rows: the join key or the right table's primary key. If you IS NULL-check a nullable right column, you'll misclassify matched rows (that legitimately have a NULL there) as orphans.
Recap: An anti-join finds rows with no counterpart via LEFT JOIN … WHERE right.key IS NULL, the backbone of orphan/FK checks; prefer it (or NOT EXISTS) over NOT IN, which breaks on NULLs.
CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER
);
INSERT INTO customers VALUES (1),(2),(3);
INSERT INTO orders VALUES
(100, 1),
(101, 9), -- orphan: customer 9 doesn't exist
(102, 2),
(103, 7); -- orphan: customer 7 doesn't existSELECT o.order_id, o.customer_id
FROM orders AS o
LEFT JOIN customers AS c
ON o.customer_id = c.customer_id
WHERE c.customer_id IS NULL;Apply
Your turn
The task this lesson builds to.
Find orders whose customer_id has no matching row in customers (orphaned orders). Return order_id and customer_id, sorted by order_id.
3 hints and 1 automated check are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
Write a query that returns the order_item_id and product_id of every orphaned line item: an order_items row whose product_id has no matching row in products. Sort by order_item_id.
3 hints and 1 automated check are waiting in the workspace.