EXISTS and NOT EXISTS are among the most powerful and misunderstood filtering tools in SQL. Learn how correlated subqueries work under the hood, why NOT EXISTS beats NOT IN every time, and how to compose multi-criteria existence checks that would tie a JOIN in knots.

You've got an orders table, a customers table, and a products table. A stakeholder walks over and asks: "Can you pull me a list of all customers who have placed at least one order in the last 90 days, but have never purchased from our enterprise product tier?" This sounds like a simple filter request, but if you reach for a JOIN to solve it, you'll quickly find yourself wrestling with duplicate rows, tricky GROUP BY clauses, and HAVING conditions that feel like they're fighting you the whole way.
This is exactly the problem that EXISTS and NOT EXISTS were designed to solve. They let you filter rows in one table based on the presence or absence of related rows in another table — without collapsing your result set through aggregation, without introducing the fan-out problems that JOINs create with one-to-many relationships, and without the NULL-poisoning traps that IN and NOT IN are famous for. When you understand correlated subqueries and how EXISTS plugs into them, you gain a genuinely different tool in your SQL toolkit — one that's often cleaner, more expressive, and even faster than the alternatives.
By the end of this lesson, you'll be thinking in terms of existence checks rather than always reaching for a JOIN. We'll cover the mechanics deeply, contrast EXISTS against its alternatives, explore anti-join patterns, and walk through the performance considerations that matter in production systems.
What you'll learn:
EXISTS and NOT EXISTS and how the query engine evaluates themIN, NOT IN, or a filtered JOINNOT EXISTS and why NOT IN is almost never the right substituteYou should be comfortable writing SELECT, WHERE, and JOIN queries before tackling this material. A working understanding of subqueries will help significantly — if you've used subqueries in WHERE clauses before but haven't gone deep on correlated subqueries, this lesson will fill that gap. Familiarity with SQL JOINs is assumed throughout.
Before we touch EXISTS, you need to have a clear mental model of what a correlated subquery is, because EXISTS is almost always used in that context.
A regular subquery runs once and produces a result that the outer query consumes:
-- Non-correlated: the inner query runs once
SELECT product_id, product_name
FROM products
WHERE category_id IN (
SELECT category_id
FROM categories
WHERE department = 'Electronics'
);
The inner query has no reference to the outer query. The database evaluates it in isolation, gets a list of category_id values, and the outer query filters against that list.
A correlated subquery is different. It references a column from the outer query, which means it can't be evaluated in isolation. It's re-evaluated for each row in the outer query:
-- Correlated: the inner query references o.customer_id from the outer query
SELECT c.customer_id, c.company_name
FROM customers c
WHERE (
SELECT COUNT(*)
FROM orders o
WHERE o.customer_id = c.customer_id
) > 5;
For each row in customers, the database runs the inner query with that specific c.customer_id value substituted in. You're essentially asking: "For this particular customer, how many orders exist?" The outer table row is in scope inside the inner query.
Key insight
Correlated subqueries introduce a conceptual loop — one execution of the subquery per row of the outer query. This is why they can be expensive, and also why EXISTS is designed to short-circuit that evaluation as early as possible.
The correlated structure is the foundation for everything EXISTS does. Now let's look at what EXISTS adds to that picture.
The EXISTS operator takes a subquery as its argument and returns TRUE if that subquery produces at least one row, FALSE if it produces no rows. That's the complete semantics. The actual values in the rows returned don't matter at all — only whether any rows exist.
SELECT c.customer_id, c.company_name, c.email
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
This returns every customer who has at least one order. The SELECT 1 inside is conventional — since EXISTS ignores the actual values returned, you could write SELECT *, SELECT 'banana', or SELECT NULL, and the behavior would be identical. Most developers write SELECT 1 as a signal to future readers that we only care about existence, not values.
Tip
You'll see SELECT 1 in EXISTS subqueries everywhere. It's a convention, not a requirement. The query optimizer in every major database (PostgreSQL, SQL Server, MySQL, Oracle) knows that EXISTS discards the projection and optimizes accordingly — it never actually materializes the result set.
Here's the critical performance optimization: when the database evaluates EXISTS, it stops scanning as soon as it finds the first matching row. If a customer has 500 orders, the engine finds the first one and immediately returns TRUE — it doesn't count all 500. This short-circuit evaluation is one of EXISTS's major advantages over COUNT(*) > 0.
The power of EXISTS comes entirely from the correlated reference — the WHERE o.customer_id = c.customer_id clause that links the inner query to the outer row. Without that correlation, you'd just be asking "does any order exist in the entire orders table?" which is nearly always TRUE and useless.
Let's extend the opening scenario:
-- Customers who placed at least one order in the last 90 days
SELECT c.customer_id, c.company_name, c.email
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.order_date >= CURRENT_DATE - INTERVAL '90 days'
);
Now the inner query checks two conditions: the customer must match, AND the order must be recent. Both conditions must be met for EXISTS to return TRUE for that customer row. Notice we get one row per customer regardless of how many recent orders they have — no aggregation needed, no GROUP BY, no DISTINCT.
If EXISTS asks "does a related row exist?", NOT EXISTS asks "does no related row exist?" This is the classic anti-join pattern — finding rows in one table that have no match in another.
-- Customers who have NEVER placed an order
SELECT c.customer_id, c.company_name
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
For each customer, we check the orders table. If no order row exists with that customer's ID, NOT EXISTS returns TRUE, and the customer appears in the result. This is conceptually an "orphan" detection — rows in the outer table with no corresponding rows in the inner table.
Now let's tackle the compound scenario from the introduction:
-- Customers with at least one recent order, but no enterprise product purchases
SELECT c.customer_id, c.company_name, c.email, c.account_tier
FROM customers c
WHERE EXISTS (
-- Has at least one order in the last 90 days
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.order_date >= CURRENT_DATE - INTERVAL '90 days'
)
AND NOT EXISTS (
-- Has never purchased an enterprise-tier product
SELECT 1
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
JOIN products p ON p.product_id = oi.product_id
WHERE o.customer_id = c.customer_id
AND p.product_tier = 'Enterprise'
);
This query is remarkably readable. It says exactly what the business asked for, in two clear clauses. The alternative — joining orders, joining items, aggregating, and applying HAVING conditions with conditional counting — would be significantly harder to write correctly and harder to explain to a colleague.
Warning
When stacking multiple EXISTS and NOT EXISTS conditions, make sure each correlated subquery references the outer table correctly. A common bug is copy-pasting a subquery and forgetting to update the correlation join condition, producing a subquery that accidentally correlates to the wrong table alias.
This is one of the most debated trade-offs in SQL. Let's be precise about when each is appropriate.
-- Using IN
SELECT c.customer_id, c.company_name
FROM customers c
WHERE c.customer_id IN (
SELECT o.customer_id
FROM orders o
WHERE o.order_date >= CURRENT_DATE - INTERVAL '90 days'
);
-- Using EXISTS
SELECT c.customer_id, c.company_name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.order_date >= CURRENT_DATE - INTERVAL '90 days'
);
For this case, both queries return identical results and most modern optimizers will produce the same execution plan for both. The optimizer is smart enough to rewrite IN as EXISTS or vice versa. So when does the distinction actually matter?
This is where EXISTS wins decisively over IN, and it's a trap that catches experienced developers. NOT IN behaves unpredictably when the subquery returns any NULL values.
Consider this: you want customers who have no orders.
-- DANGEROUS: if any order has a NULL customer_id, this returns ZERO rows
SELECT c.customer_id, c.company_name
FROM customers c
WHERE c.customer_id NOT IN (
SELECT o.customer_id
FROM orders o
);
If even a single row in the orders table has customer_id = NULL, then NOT IN returns nothing. Zero rows. No error message, no warning — just silently wrong results.
Why? Because SQL uses three-valued logic (TRUE, FALSE, UNKNOWN). When you evaluate 5 NOT IN (1, 2, NULL), SQL expands this to 5 != 1 AND 5 != 2 AND 5 != NULL. But 5 != NULL evaluates to UNKNOWN, not TRUE. And TRUE AND TRUE AND UNKNOWN is UNKNOWN. A WHERE clause only passes rows where the condition is TRUE, so UNKNOWN rows get filtered out. Your entire result set vanishes.
NOT EXISTS does not have this problem:
-- SAFE: NULL customer_id in orders does not affect this result
SELECT c.customer_id, c.company_name
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
When the correlated condition is o.customer_id = c.customer_id and o.customer_id is NULL, the comparison evaluates to UNKNOWN, which means the WHERE clause fails — that order row simply doesn't match. The outer customer still gets checked against all other order rows normally. The NULL row doesn't poison the entire predicate.
Warning
Never use NOT IN with a subquery unless you are 100% certain the subquery column cannot contain NULLs, and that certainty is enforced by a NOT NULL constraint. Use NOT EXISTS instead. This advice applies even when you're confident about the current data — future data changes and schema evolution can introduce NULLs silently.
For more detail on how NULLs affect SQL logic throughout your queries, see NULL Handling in SQL: IS NULL, COALESCE, and NULLIF.
When the subquery returns a very large set of values, IN must materialize that entire list in memory before the outer query can filter against it. EXISTS with a correlated condition and appropriate indexes can short-circuit early and avoid materializing anything. For large datasets, EXISTS can dramatically outperform IN.
However, for small, bounded lists of literal values (WHERE status IN ('active', 'pending', 'trial')), IN is cleaner and perfectly appropriate. The EXISTS pattern is for subqueries against tables, not literal value lists.
Let's be concrete about why EXISTS often beats JOIN for existence filtering.
Suppose you want all customers with at least one order. The JOIN approach:
-- JOIN approach: fan-out problem
SELECT DISTINCT c.customer_id, c.company_name
FROM customers c
INNER JOIN orders o ON o.customer_id = c.customer_id;
Notice the DISTINCT. Without it, you'd get one row per order, not per customer. A customer with 20 orders shows up 20 times. The DISTINCT adds a sort or hash operation to deduplicate, which is extra work.
-- EXISTS approach: no fan-out
SELECT c.customer_id, c.company_name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
No DISTINCT needed. No fan-out. One row per customer, always. The EXISTS subquery is a filter, not a join — it never multiplies rows.
The JOIN approach isn't wrong, but it carries cognitive overhead. You have to remember to DISTINCT, and if you're selecting additional columns (especially aggregates), the interaction between JOIN fan-out and those aggregates gets complicated quickly. Multi-table reporting with JOIN and GROUP BY covers those aggregation patterns in depth, but for pure existence filtering, EXISTS keeps things simpler.
The exception: when you need columns from the related table in your SELECT list, you need a JOIN. EXISTS only tells you whether something exists — it doesn't let you project values from the inner query. If you need the date of the most recent order alongside customer info, you'll need a JOIN or a subquery in the SELECT list.
Now that we've covered the fundamentals, let's look at patterns that arise repeatedly in production analytical work.
Find customers who placed their very first order in the current month:
SELECT c.customer_id, c.company_name, c.signup_date
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND DATE_TRUNC('month', o.order_date) = DATE_TRUNC('month', CURRENT_DATE)
)
AND NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.order_date < DATE_TRUNC('month', CURRENT_DATE)
);
The first EXISTS confirms they have an order this month. The NOT EXISTS confirms they have no order before this month. Together: first-time buyers this month, expressed clearly and without window functions.
Find products that appear in at least one confirmed order placed by an enterprise-tier customer:
SELECT p.product_id, p.product_name, p.product_tier, p.price
FROM products p
WHERE EXISTS (
SELECT 1
FROM order_items oi
JOIN orders o ON o.order_id = oi.order_id
JOIN customers c ON c.customer_id = o.customer_id
WHERE oi.product_id = p.product_id
AND o.status = 'confirmed'
AND c.account_tier = 'Enterprise'
);
Notice that EXISTS subqueries can themselves contain JOINs. The inner query joins across three tables to check a multi-hop condition. The outer query sees a simple existence check. This is a powerful compositional pattern — you're writing a complex filter but the outer query stays clean.
Key insight
EXISTS subqueries are not limited to a single table. You can write arbitrarily complex queries inside EXISTS — multiple joins, aggregations, even nested subqueries. The outer query only sees TRUE or FALSE.
Sometimes you want rows where exactly one related record exists. EXISTS alone doesn't express cardinality constraints beyond "at least one," but you can combine it with NOT EXISTS to build precision:
-- Orders that have exactly one line item (not zero, not two or more)
SELECT o.order_id, o.customer_id, o.order_date, o.total_amount
FROM orders o
WHERE EXISTS (
-- At least one item exists
SELECT 1
FROM order_items oi
WHERE oi.order_id = o.order_id
)
AND NOT EXISTS (
-- No second item exists
SELECT 1
FROM order_items oi1
JOIN order_items oi2
ON oi2.order_id = oi1.order_id
AND oi2.line_item_id > oi1.line_item_id
WHERE oi1.order_id = o.order_id
);
This is admittedly unusual — a COUNT(*) = 1 subquery is cleaner here — but it demonstrates how EXISTS can be combined to express precise cardinality. For most "exactly N" requirements, the correlated COUNT approach is more readable.
You can use aggregate conditions inside an EXISTS subquery. This is different from using aggregate functions in the SELECT list — we're filtering based on aggregated properties of related records.
Find customers whose average order value exceeds $1,000:
-- Readable but potentially slow: scalar subquery in WHERE
SELECT c.customer_id, c.company_name
FROM customers c
WHERE (
SELECT AVG(o.total_amount)
FROM orders o
WHERE o.customer_id = c.customer_id
) > 1000;
Alternatively using HAVING inside EXISTS:
SELECT c.customer_id, c.company_name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
HAVING AVG(o.total_amount) > 1000
);
Both achieve the same result. The scalar subquery version is slightly more readable. The EXISTS with HAVING is a valid pattern that some query planners optimize differently. Test both with your specific database and data volume.
For a deeper dive into how aggregation interacts with filtering, see Master SQL Aggregate Functions: Advanced GROUP BY, HAVING, and Performance Optimization.
NOT EXISTS is invaluable for referential integrity checks and data quality audits:
-- Find order_items referencing non-existent products (orphaned records)
SELECT oi.order_item_id, oi.order_id, oi.product_id, oi.quantity
FROM order_items oi
WHERE NOT EXISTS (
SELECT 1
FROM products p
WHERE p.product_id = oi.product_id
);
-- Find customers referenced in orders but missing from customer master
SELECT DISTINCT o.customer_id
FROM orders o
WHERE NOT EXISTS (
SELECT 1
FROM customers c
WHERE c.customer_id = o.customer_id
);
-- Find products with no category assignment
SELECT p.product_id, p.product_name, p.category_id
FROM products p
WHERE NOT EXISTS (
SELECT 1
FROM categories cat
WHERE cat.category_id = p.category_id
);
These data quality patterns are often more convenient than LEFT JOIN ... WHERE right_key IS NULL because they express intent clearly and don't require understanding join null semantics.
Understanding performance requires understanding how the query engine actually evaluates EXISTS. Let's work through this carefully.
The naive model: "The database runs the inner query once for every outer row." If customers has 100,000 rows and orders has 5,000,000 rows, that sounds horrifying — 100,000 subquery executions.
The reality: modern query planners almost never do this. They transform correlated EXISTS subqueries into JOIN operations internally, typically a semi-join or anti-join. The correlated "loop" you see in the SQL is a logical description of the result, not a literal execution plan.
-- What you write (logically)
FOR EACH customer:
IF any order exists for this customer: include
-- What the engine actually does (typically)
HASH JOIN or MERGE JOIN between customers and orders
with early termination on first match per customer
For EXISTS to be fast, you need the right indexes. The critical index is on the correlated join column in the inner query's table:
-- For this query:
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id -- <-- this is the correlation
AND o.order_date >= '2024-01-01' -- <-- this is an additional filter
)
You want a composite index on (customer_id, order_date) in the orders table. The engine can seek to rows for a specific customer_id and then filter on order_date within that range. Without an index on customer_id, the engine must full-scan orders for each outer row — this is the scenario where correlated subqueries genuinely are slow.
-- Index that supports the EXISTS pattern above
CREATE INDEX idx_orders_customer_date
ON orders (customer_id, order_date);
For NOT EXISTS (the anti-join), the same index helps. The engine performs an index seek and returns quickly when no rows match, rather than scanning the entire table to confirm absence.
Tip
For anti-join patterns with NOT EXISTS, check your execution plan specifically for index seeks on the inner query. If you see a table scan or index scan, you're missing the right index. In PostgreSQL, use EXPLAIN ANALYZE; in SQL Server, use SET STATISTICS IO ON and the execution plan viewer; in MySQL, use EXPLAIN FORMAT=JSON.
For a thorough treatment of how indexes affect query plans, see SQL Indexes Explained: How They Work and When to Create Them.
In PostgreSQL, examine an EXISTS query:
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.customer_id, c.company_name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.order_date >= '2024-01-01'
);
What to look for in the plan:
Good signs:
Hash Semi Join — the planner optimized the EXISTS into a hash semi-join. This is efficient for large datasets.Nested Loop Semi Join with Index Scan — for smaller inner result sets, the planner uses a nested loop but with an index seek. Early termination per outer row.Warning signs:
Seq Scan on the inner table — no usable index found. Add an index.Nested Loop without Semi — the planner may have missed the semi-join optimization. Check for implicit type mismatches in the correlation condition.The honest answer is: it depends, and you should test. Modern optimizers are excellent at transforming one into the other. But there are systematic differences:
EXISTS tends to win when:
JOIN tends to win when:
For the NOT EXISTS / anti-join case, NOT EXISTS is consistently a safer choice than LEFT JOIN ... WHERE right_key IS NULL. Both express the same logical operation, but NOT EXISTS makes the intent explicit, avoids NULL confusion, and gives the optimizer a clear anti-join hint. See Advanced JOIN Patterns: Self Joins, Anti Joins, and Semi Joins for the full comparison of anti-join strategies.
EXISTS isn't just for SELECT queries. It's extremely useful in UPDATE and DELETE statements where you need to modify rows based on the existence of related data.
-- Mark customers as 'at-risk' if they have no order in the past 180 days
UPDATE customers
SET status = 'at-risk', updated_at = CURRENT_TIMESTAMP
WHERE status = 'active'
AND NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = customers.customer_id
AND o.order_date >= CURRENT_DATE - INTERVAL '180 days'
);
-- Delete promotional records for products that no longer exist
DELETE FROM product_promotions pp
WHERE NOT EXISTS (
SELECT 1
FROM products p
WHERE p.product_id = pp.product_id
);
-- Flag duplicate customer records (all but the oldest registration)
UPDATE customers c
SET is_duplicate = TRUE
WHERE EXISTS (
SELECT 1
FROM customers c2
WHERE c2.email = c.email
AND c2.customer_id < c.customer_id -- older record exists with same email
);
Warning
Always wrap destructive operations using NOT EXISTS in a transaction and test with a SELECT first. The pattern DELETE ... WHERE NOT EXISTS is powerful but can be dangerous if the correlated condition is subtly wrong. Replace DELETE with SELECT and inspect the result set before committing.
For safety patterns around data modification, see INSERT, UPDATE, DELETE: Master SQL Data Modification Safely.
When your EXISTS conditions become complex, CTEs can dramatically improve readability by letting you name intermediate concepts.
-- Find high-value customers who are NOT in any active retention campaign
-- AND have not made a purchase in the last 60 days
-- AND have a lifetime value > $10,000
WITH high_value_customers AS (
SELECT customer_id
FROM customers
WHERE lifetime_value > 10000
),
customers_in_campaigns AS (
SELECT DISTINCT customer_id
FROM campaign_enrollments ce
JOIN campaigns camp ON camp.campaign_id = ce.campaign_id
WHERE camp.status = 'active'
),
recently_active AS (
SELECT DISTINCT customer_id
FROM orders
WHERE order_date >= CURRENT_DATE - INTERVAL '60 days'
)
SELECT c.customer_id, c.company_name, c.email, c.lifetime_value
FROM customers c
JOIN high_value_customers hvc ON hvc.customer_id = c.customer_id
WHERE NOT EXISTS (
SELECT 1
FROM customers_in_campaigns cic
WHERE cic.customer_id = c.customer_id
)
AND NOT EXISTS (
SELECT 1
FROM recently_active ra
WHERE ra.customer_id = c.customer_id
)
ORDER BY c.lifetime_value DESC;
Here we've used CTEs to pre-compute the sets we need, then used NOT EXISTS to filter against them. The EXISTS conditions become simple and readable. The business logic is obvious.
Note
When EXISTS is used against a CTE, the optimizer may or may not materialize the CTE depending on your database. In PostgreSQL prior to version 12, CTEs were always materialized (optimization fences). In PostgreSQL 12+, the optimizer can inline CTEs. SQL Server also has complex CTE materialization behavior. This affects whether the EXISTS short-circuit optimization applies to the CTE-backed inner query. If performance matters, test with and without CTE materialization.
For a comprehensive treatment of CTE patterns, see Advanced Subqueries and CTEs: Mastering Complex SQL Query Architecture.
EXISTS is standard SQL, but there are nuances across databases worth knowing.
PostgreSQL's planner is sophisticated at transforming EXISTS into semi-joins and anti-joins. The EXPLAIN output will show Hash Semi Join or Nested Loop Semi Join. PostgreSQL also supports EXISTS in the SELECT list via CASE:
-- Boolean flag using EXISTS
SELECT
c.customer_id,
c.company_name,
CASE WHEN EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
) THEN 'Has Orders' ELSE 'No Orders' END AS order_status
FROM customers c;
SQL Server handles EXISTS well and typically produces efficient anti-join plans. One SQL Server-specific pattern uses EXISTS in MERGE statements:
-- SQL Server: conditional update using EXISTS
MERGE INTO customers AS target
USING (
SELECT DISTINCT customer_id FROM orders WHERE order_date >= '2024-01-01'
) AS source ON target.customer_id = source.customer_id
WHEN MATCHED THEN
UPDATE SET target.is_recent_buyer = 1;
SQL Server also allows EXISTS directly in CHECK constraints (though rarely used in practice).
MySQL's handling of correlated EXISTS improved significantly in version 8.0. Earlier versions sometimes failed to apply semi-join optimizations, leading to genuine row-by-row execution. If you're on MySQL 5.7 or older, test your EXISTS queries carefully with EXPLAIN and consider rewriting critical ones as JOINs if performance is unacceptable.
Oracle uses the term "semi-join" in its execution plans for EXISTS optimization. Oracle has historically been very good at EXISTS optimization and often produces identical plans for EXISTS and equivalent JOIN formulations.
Work through these exercises using a schema with tables: customers, orders, order_items, products, and categories.
Schema reference:
customers (customer_id, company_name, email, account_tier, signup_date, lifetime_value, status)
orders (order_id, customer_id, order_date, status, total_amount)
order_items (order_item_id, order_id, product_id, quantity, unit_price)
products (product_id, product_name, category_id, product_tier, price, is_active)
categories (category_id, category_name, department)
Exercise 1 — Basic EXISTS:
Write a query that returns all active products (where is_active = TRUE) that have been ordered at least once. Your result should include product_id, product_name, and price.
Exercise 2 — NOT EXISTS:
Write a query that finds all categories where no product in that category has ever been ordered. Return category_id and category_name.
Exercise 3 — Compound conditions: Find all customers with account_tier = 'Enterprise' who have placed at least three distinct orders (tip: you'll need a COUNT inside the EXISTS subquery or combine with a HAVING), but who have NOT purchased any product from the 'Analytics' product_tier in the last 12 months.
Exercise 4 — Data quality:
Write a NOT EXISTS query to find all order_items that reference a product_id that doesn't exist in the products table (orphaned line items). This is a data quality audit query.
Exercise 5 — UPDATE with EXISTS:
Write an UPDATE statement that sets customers.status = 'churned' for any customer with status = 'active' who has no orders with status = 'confirmed' in the past 365 days.
Exercise 6 — Performance challenge:
Take your query from Exercise 3 and write out the CREATE INDEX statements that would optimize it. Explain which column combinations to index and in which order.
-- BUG: Every customer would be included because SOME order always exists
WHERE EXISTS (
SELECT 1
FROM orders o
-- Missing: WHERE o.customer_id = c.customer_id
WHERE o.order_date >= '2024-01-01'
)
Without the correlation, EXISTS asks "does any row in orders exist?" which is almost always TRUE. Every outer row passes the filter. This is a silent correctness bug — no error, just wrong results.
Fix: Always verify that your EXISTS subquery contains a WHERE clause that references the outer table. If it doesn't, you almost certainly have a bug.
-- DANGEROUS: silent wrong results if orders.customer_id contains NULLs
WHERE customer_id NOT IN (SELECT customer_id FROM orders)
-- CORRECT: NULL-safe
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id)
We covered this earlier, but it bears repeating because the failure mode is silent. You get zero rows with no error message.
-- INVALID: you cannot reference inner query columns in outer SELECT
SELECT c.customer_id, o.order_date -- o.order_date is not available here
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
EXISTS returns TRUE or FALSE — you cannot access columns from the inner query in the outer SELECT list. If you need columns from the related table, use a JOIN or a correlated scalar subquery in the SELECT list.
-- customer_id is VARCHAR in customers but INTEGER in orders
-- Implicit cast prevents index usage
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id -- silent type coercion
)
If the correlated columns have different data types, the database applies an implicit cast — typically to the higher-precedence type. This prevents index usage on the casted column, turning an index seek into a full scan. Always ensure correlated columns share the same data type. This is a schema design concern; see SQL Data Types and Schema Design Mastery for guidance.
-- Both of these behave identically — NULLs in the SELECT list don't matter
WHERE EXISTS (SELECT NULL FROM orders o WHERE o.customer_id = c.customer_id)
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id)
Developers sometimes worry that SELECT NULL inside EXISTS would cause EXISTS to return FALSE. It doesn't. EXISTS only cares whether a row is returned — a row containing NULL is still a row. This is different from how NULLs behave in IN and NOT IN.
-- Overly complex: using EXISTS where a simple WHERE suffices
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.order_id = orders.order_id -- correlating to the same table
AND o.status = 'confirmed'
)
-- Simpler and equivalent:
WHERE orders.status = 'confirmed'
EXISTS is for cross-table existence checks. Correlated subqueries that reference the same table as the outer query can almost always be simplified to a direct WHERE condition.
EXISTS and NOT EXISTS are fundamentally about expressing membership and exclusion — is this row related to at least one row in another table? — without the collateral damage of JOINs (fan-out, duplicate rows) or the NULL dangers of IN/NOT IN. They're correlated subquery patterns, which means the inner query is re-evaluated in the context of each outer row, but modern optimizers translate this into efficient semi-join and anti-join operations.
The patterns we covered:
The critical rule to carry forward: always use NOT EXISTS instead of NOT IN for subqueries. This is non-negotiable in production SQL. The NULL behavior of NOT IN is a reliability landmine.
Where to go next:
EXISTS is one of those SQL features that, once you've internalized it, you'll see applications everywhere. The moment someone asks you a question that includes the words "at least one," "any," "never," or "no," reach for EXISTS or NOT EXISTS first. It's the right tool for the job.