SQL EXISTS
The EXISTS operator tests whether a subquery returns at least one row. It doesn’t care what the subquery returns — only whether it returns anything — which makes it one of the fastest and most reliable ways to check for related data. You’ll see it constantly in real-world SQL: “find all customers who have placed an order,” “find departments with no employees,” “find products that were never sold.” This lesson covers exactly how EXISTS works, how it differs from IN and JOIN, and the subtle mistakes that trip people up.
Overview: How EXISTS Works
EXISTS is a boolean operator that wraps a subquery and evaluates to TRUE if that subquery returns one or more rows, or FALSE if it returns zero rows. It is almost always used as a correlated subquery — meaning the inner query references a column from the outer query, so it effectively runs once per outer row (logically speaking; a good query optimizer rewrites this into something far more efficient).
Because EXISTS only checks for row existence, the columns you list in the subquery’s SELECT clause are irrelevant to the result — the database engine doesn’t need to fetch or return them. That’s why you’ll commonly see SELECT 1 or SELECT * inside an EXISTS subquery: it signals to any reader that the actual column list doesn’t matter.
EXISTS vs. IN vs. JOIN
These three tools often solve the same problem, but they behave differently:
| Approach | Behavior | NULL-safety |
|---|---|---|
EXISTS |
Stops at the first matching row; ignores subquery column values | Safe — unaffected by NULLs in the subquery |
IN |
Compares a value against a list produced by the subquery | Unsafe — a single NULL in the list can silently break NOT IN |
JOIN |
Combines matching rows; can duplicate outer rows if there are multiple matches | Safe, but requires DISTINCT to avoid duplicate rows when checking existence |
Because a plain JOIN multiplies rows for every match, and IN/NOT IN have NULL pitfalls, EXISTS/NOT EXISTS is generally the safest and most efficient way to answer a pure yes/no “does related data exist” question.
Syntax
SELECT column_list
FROM outer_table AS o
WHERE [NOT] EXISTS (
SELECT 1 -- column list is ignored, 1 is a common convention
FROM inner_table AS i
WHERE i.related_column = o.related_column -- the correlation
[AND additional_conditions]
);
| Part | Meaning |
|---|---|
EXISTS ( subquery ) |
Evaluates to TRUE if the subquery returns at least one row |
NOT EXISTS ( subquery ) |
Evaluates to TRUE if the subquery returns zero rows |
i.related_column = o.related_column |
The correlation condition linking inner and outer rows — without this, EXISTS becomes a constant check that ignores the outer row entirely |
SELECT 1 |
A placeholder; any expression works since the values are never returned to the outer query |
Examples
Example 1: Customers who have placed at least one order
Given a customers table and an orders table, we want every customer who appears at least once in orders.
SELECT customer_name, country
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
)
ORDER BY customer_name;
Result:
| customer_name | country |
|---|---|
| Alice Johnson | USA |
| Bob Smith | UK |
Alice and Bob each have at least one row in orders whose customer_id matches theirs, so the subquery finds a match and EXISTS is true. Carla and David never appear in orders, so their subquery returns zero rows and they’re excluded.
Example 2: Customers who have never ordered (NOT EXISTS)
Flip the logic to find customers with no matching orders — a common “find the gap” query.
SELECT customer_name, country
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
)
ORDER BY customer_name;
Result:
| customer_name | country |
|---|---|
| Carla Diaz | Canada |
| David Lee | USA |
This is the mirror image of Example 1: Carla and David have zero matching rows in orders, so NOT EXISTS is true for them, while Alice and Bob are filtered out.
Example 3: A self-referencing correlated subquery
EXISTS subqueries don’t have to reference a different table — you can correlate a table against itself. Here we find every department that contains at least one employee earning over 80,000.
SELECT DISTINCT e1.department
FROM employees e1
WHERE EXISTS (
SELECT 1
FROM employees e2
WHERE e2.department = e1.department
AND e2.salary > 80000
);
Result:
| department |
|---|
| Engineering |
For every Engineering row (Maria and Tom), the correlated subquery searches Engineering rows again and finds Maria’s 95,000 salary, so the condition is satisfied for both — but DISTINCT collapses that to a single result row. Sales and Marketing have no employee above 80,000, so their subqueries return zero rows and those departments are excluded entirely.
How It Works Step by Step (Under the Hood)
Logically, the engine processes an EXISTS query in this order:
- FROM — the outer table is scanned row by row.
- WHERE (EXISTS check) — for each outer row, the correlated subquery runs using that row’s values. The engine looks for a single matching row and stops as soon as it finds one — it never needs to count or fetch all matches, which is why EXISTS can be faster than
COUNT(*) > 0or an uncorrelatedINsubquery over a large table. - SELECT — columns from the outer row are projected for rows that passed the EXISTS test.
- ORDER BY — the final result set is sorted, if requested.
In practice, modern query planners (including SQLite’s) don’t literally re-run the subquery once per outer row — they typically rewrite a correlated EXISTS into a semi-join, an internal join strategy that returns each outer row at most once, regardless of how many inner matches exist. This is exactly why EXISTS avoids the duplicate-row problem that a plain JOIN has. Having an index on the correlated column (here, orders.customer_id) lets the engine look up matches directly instead of scanning the whole inner table for every outer row.
Common Mistakes
Mistake 1: Using NOT IN instead of NOT EXISTS when the subquery can return NULL
This is the single most common EXISTS-related bug. If the subquery column contains even one NULL, NOT IN silently returns zero rows for the entire query — no error, just wrong results.
-- orders.customer_id contains a NULL for one row
SELECT customer_name
FROM customers
WHERE customer_id NOT IN (SELECT customer_id FROM orders);
-- Returns 0 rows, even though customer 2 has no order at all!
This happens because NOT IN (1, NULL, 3) is really customer_id <> 1 AND customer_id <> NULL AND customer_id <> 3. Comparing anything to NULL produces UNKNOWN, and UNKNOWN anywhere in an AND chain (unless another condition is definitively FALSE) makes the whole row get filtered out of the WHERE clause. The fix is NOT EXISTS, which is immune to this because it only checks for a matching row, never comparing against the full list:
SELECT customer_name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
)
ORDER BY customer_name;
-- Correctly returns Bob Smith
Mistake 2: Forgetting the correlation (typos in the join column)
An EXISTS subquery is only useful when it’s correctly correlated to the outer row. A simple typo in the correlated column name won’t just give wrong results — it will often fail outright:
SELECT customer_name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.customre_id
);
-- Error: no such column: c.customre_id
Always double-check that the correlation condition references real, correctly-spelled columns from both tables. If a query like this doesn’t error (for example, if the typo happens to match a different real column), it’s even more dangerous — it will run and quietly return misleading results.
Best Practices
- Prefer
EXISTS/NOT EXISTSoverIN/NOT INwhenever the subquery’s column could containNULLvalues. - Prefer
EXISTSover a plainJOINwhen you only need a yes/no existence check — it avoids duplicate outer rows and doesn’t require an extraDISTINCT. - Use
SELECT 1(orSELECT *) inside the subquery to make it clear to readers that the projected columns don’t matter. - Always include a correlation condition that ties the subquery back to the outer query’s columns — an uncorrelated
EXISTSevaluates to the same constant value for every outer row. - Index the column used in the correlation condition (e.g. a foreign key like
orders.customer_id) so the engine can look up matches quickly instead of scanning. - Use
NOT EXISTSfor “find the gap” style reports — orphaned records, customers with no orders, products never sold — it reads clearly and performs well.
Practice Exercises
- Exercise 1: Using a
productstable and anorderstable (assume orders has aproduct_idcolumn), write a query usingEXISTSto list every product that has never been ordered. - Exercise 2: Rewrite the following query to use
NOT EXISTSinstead ofNOT IN, and explain why the rewrite is safer:SELECT name FROM employees WHERE id NOT IN (SELECT manager_id FROM employees). - Exercise 3: Write a correlated
EXISTSquery that finds every department where the average salary is above 70,000. (Hint: your subquery will needGROUP BYandHAVINGinside it.)
Summary
EXISTStests only whether a subquery returns any rows — the subquery’s column values are irrelevant.- It is almost always used with a correlated subquery that references a column from the outer query.
NOT EXISTSis the safe alternative toNOT INwhen the subquery’s column can containNULL.- Query engines typically rewrite correlated
EXISTSinto an efficient semi-join and stop searching at the first match. - Forgetting the correlation condition, or misspelling the correlated column, are the most common bugs to watch for.
