Understanding Semi Joins and Anti Joins in SQL
INNER JOIN is often used to find matching rows, but it can create duplicate results if the matching table has multiple records. Semi-joins and anti-joins are conceptual patterns for existence checks, often implemented with EXISTS or NOT EXISTS.
⚡ Key Insight: Semi-joins find rows that have a match. Anti-joins find rows that don't have a match. Both are essential for existence-based queries.
The Problem: INNER JOIN Duplication
You need a list of customers who have placed at least one order over $100. A simple INNER JOIN would return each qualifying order, duplicating customer data.
-- ❌ Duplicates customer data
SELECT c.customer_id, c.customer_name, o.order_id, o.amount
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.amount > 100;
If a customer has 5 orders over $100, they appear 5 times. For a customer list, this is incorrect.
Solution 1: Semi-Join with EXISTS
A semi-join returns each customer only once, regardless of how many qualifying orders they have. It "semi-joins" the customer table with the orders table.
-- ✅ Clean approach with EXISTS
SELECT c.customer_id, c.customer_name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.amount > 100
);
💡 Pro Tip: EXISTS often outperforms IN on large datasets because it can stop after the first match, while IN often builds a full result set.
Solution 2: Anti-Join with NOT EXISTS
An anti-join finds customers who have no orders over $100. It's the complement of the semi-join.
-- ✅ Clean approach with NOT EXISTS
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
AND o.amount > 100
);
Alternative: Using IN and NOT IN
The IN operator can also implement semi-joins and anti-joins. However, EXISTS is often more performant and handles NULL values more safely.
-- Using IN (semi-join)
SELECT customer_id, customer_name
FROM customers
WHERE customer_id IN (
SELECT customer_id
FROM orders
WHERE amount > 100
);
-- Using NOT IN (anti-join) - BE CAREFUL WITH NULLS!
SELECT customer_id, customer_name
FROM customers
WHERE customer_id NOT IN (
SELECT customer_id
FROM orders
WHERE amount > 100
);
⚠️ Warning: NOT IN with a subquery that can return NULL values will return no results. NOT EXISTS is the safer choice.
EXISTS vs. IN: Performance Comparison
| Scenario | EXISTS | IN |
|---|---|---|
| Large outer table, small inner table | Good | Better |
| Large inner table, small outer table | Better | Good |
| NULL handling | Safe | Risky (for NOT IN) |
| Early termination | Yes | Sometimes |
Real-World Scenarios
1. Finding Inactive Users
SELECT u.id, u.username, u.email
FROM users u
WHERE NOT EXISTS (
SELECT 1
FROM logins l
WHERE l.user_id = u.id
AND l.login_date > DATE_SUB(NOW(), INTERVAL 30 DAY)
);
2. Finding Products Never Ordered
SELECT p.product_id, p.product_name
FROM products p
WHERE NOT EXISTS (
SELECT 1
FROM order_items oi
WHERE oi.product_id = p.product_id
);
3. High-Value Customers with Multiple Orders
SELECT c.customer_id, c.customer_name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.amount > 1000
HAVING COUNT(*) > 3
);
Best Practices
✅ Use EXISTS for Large Datasets
EXISTS can stop after finding the first match, making it efficient for large inner tables.
✅ Use IN for Small Lists
IN with a small, known list (not a subquery) is very fast.
✅ Avoid NOT IN with NULLs
Use NOT EXISTS instead of NOT IN when subqueries may return NULL values.
✅ Test Performance
Always test both approaches with your actual data volumes to see which performs better.
Conclusion
📌 Key Takeaway: Semi-joins with EXISTS and anti-joins with NOT EXISTS are essential tools for existence-based queries. They avoid duplication, handle NULLs safely, and often outperform IN and NOT IN on large datasets.
🔍 Further Reading: Explore EXISTS with correlated subqueries, performance tuning, and advanced anti-join patterns for complex business rules.