Hook: Anti join is the database version of a bouncer: it lets in the rows that do not match anyone on the other side.
Question: What is an anti join in SQL?
Answer: An anti join returns rows from the left table only when no matching row exists in the right table. In simple words, it answers “show me the rows that are missing a partner.” Most SQL databases do not have a literal ANTI JOIN keyword in normal query syntax; instead, you express it with patterns like NOT EXISTS or LEFT JOIN ... IS NULL.
Interview-Ready Answer: I use an anti join when I want rows from one table that have no match in another table. In SQL, I usually write that as NOT EXISTS, because it is clear and null-safe. For example, if I want customers with no orders, I would select customers where no matching order row exists.
Detailed Explanation: Think of anti join as a filter on the left side. Start with every row in table A, then remove any row that finds at least one match in table B using the join condition. The result keeps only the “unmatched” rows from A.
customer_id = id.A logical anti join is the idea: keep left rows with no match. A physical anti join is the execution method the engine picks to do it efficiently.
| Form | Behavior | Null-safe? | Notes |
|---|---|---|---|
NOT EXISTS | Anti join | Yes | Usually the safest choice |
LEFT JOIN ... IS NULL | Anti join | Yes, if written carefully | Keep right-side filters in the join condition |
NOT IN | Looks similar | No, if subquery can return NULL | Can return zero rows unexpectedly |
EXCEPT | Set difference | Yes | Removes duplicates, so it is not always the same as anti join |
If the engine uses a hash anti join, time is often close to O(left + right) and memory is roughly proportional to the right-side build set. If the right side has 1 million keys, the hash table may need tens of megabytes or more depending on data type and engine. With nested loops and no useful index, the cost can degrade toward O(left × right), which gets painful fast. A small left table probing an indexed right table can still be fast because each probe becomes a quick index lookup.
Important detail: anti join stops searching a left row as soon as one match is found. That early exit is why it is often cheaper than a full join when you only care about existence, not the matched data.
NOT IN is dangerous if the subquery can return NULL; the whole predicate can become unknown and filter out everything.LEFT JOIN ... IS NULL, put extra right-table filters inside the ON clause, not the WHERE clause, or you can accidentally change the meaning.Memory hook: anti join is “the guest list bouncer.” If your name is found on the right-side list, you are out; if not, you get in.
Real-World Story: Imagine a subscription billing service that runs every night to find active customers who never created an invoice. The job uses an anti join to return customer IDs with no matching invoice row, then sends them a reminder or opens a support ticket.
What goes wrong when someone misunderstands anti join? A developer writes NOT IN (SELECT customer_id FROM invoices), but the invoices table contains a single NULL customer ID from a bad import. Suddenly the query returns no customers at all. The nightly job logs “0 candidates found,” reminders stop going out, and finance notices unpaid accounts piling up. Users do not see an obvious crash; they just stop getting emails, which makes the bug harder to spot.
Typical symptoms are empty result sets, suspiciously fast job completion, and log lines that show the subquery returning rows with nulls. In production, that kind of mistake can cause missed billing, missed fraud checks, or orphaned workflow records that never get processed.
-- Anti join demo in PostgreSQL-compatible SQL.
-- We want: customers who have NO matching orders.
-- The sample data includes a NULL customer_id in orders to show why NOT IN can fail.
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS customers;
CREATE TEMP TABLE customers (
customer_id INT PRIMARY KEY,
customer_name TEXT NOT NULL
);
CREATE TEMP TABLE orders (
order_id INT PRIMARY KEY,
customer_id INT NULL,
order_total NUMERIC(10,2) NOT NULL
);
INSERT INTO customers (customer_id, customer_name) VALUES
(1, 'Ava'),
(2, 'Ben'),
(3, 'Chloe'),
(4, 'Diego');
INSERT INTO orders (order_id, customer_id, order_total) VALUES
(101, 1, 25.00),
(102, 1, 13.50),
(103, 2, 99.99),
(104, NULL, 7.25); -- Bad data: NULL in the foreign key column breaks NOT IN logic.
-- Correct and null-safe: anti join via NOT EXISTS.
-- This returns Chloe and Diego because no row in orders matches their customer_id.
SELECT c.customer_id, c.customer_name
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
)
ORDER BY c.customer_id;
-- Equivalent pattern: LEFT JOIN + IS NULL.
-- The right-side join key must be the one you test for NULL.
SELECT c.customer_id, c.customer_name
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.customer_id IS NULL
ORDER BY c.customer_id;
-- Dangerous pattern: NOT IN with a NULL in the subquery.
-- Because orders.customer_id contains NULL, the predicate becomes UNKNOWN for every row,
-- so this query returns zero rows even though some customers really have no orders.
SELECT c.customer_id, c.customer_name
FROM customers c
WHERE c.customer_id NOT IN (
SELECT o.customer_id
FROM orders o
)
ORDER BY c.customer_id;
Follow-up & Tricky Questions:
NOT EXISTS usually preferred? It is clear, null-safe, and maps directly to the “no matching row exists” idea. Optimizers also understand it well and often turn it into an efficient anti join plan.LEFT JOIN ... IS NULL safe? It is safe when the NULL check is on the right-side join key and all extra right-table filters stay inside the ON clause. If you put those filters in WHERE, you can accidentally remove the very rows you wanted to detect.EXCEPT? Not exactly. EXCEPT is set-based and removes duplicates, while anti join works like a row-by-row filter and preserves duplicate left rows.NOT IN always behave like anti join? No. If the subquery can return even one NULL, the whole predicate can fail to match anything, which is the classic SQL trap.NOT EXISTS and some LEFT JOIN ... IS NULL queries into a physical anti join plan if that is cheaper.LEFT JOIN with WHERE right.id IS NULL always correct? No, because if you add extra right-side filters in WHERE, you can turn the query into something else. The safer pattern is usually NOT EXISTS.NOT IN return zero rows when the table clearly has non-matching rows? Because SQL three-valued logic treats comparisons with NULL as unknown, and unknown in a WHERE clause is filtered out. One NULL in the subquery is enough to poison the result.Common Mistakes:
NOT IN on a nullable subquery column. Fix: prefer NOT EXISTS, or filter out NULL values very carefully.WHERE clause after a LEFT JOIN. Fix: keep matching conditions in ON so the anti-join meaning stays intact.Memory Hook: “The bouncer checks the guest list: if your name appears anywhere on the right, you stay out; otherwise, you get in.”
Cheat Sheet:
NOT EXISTS is the safest common SQL form.LEFT JOIN ... IS NULL can work, but place filters carefully.NOT IN can break when the subquery has NULL.O(left + right); nested loops can be much worse without indexes.EXCEPT removes duplicates.Practice Tasks:
NOT EXISTS.LEFT JOIN ... IS NULL and confirm the result is identical.NULL into the right table and observe how NOT IN changes compared with NOT EXISTS.