SQL IN vs EXISTS: Differences, Performance and NULLs
IN checks whether a value is in a list. EXISTS checks whether a subquery returns at least one row. For "customers who have an order" both give the same answer. They differ in how they are written, in how NOT IN behaves when the list contains a NULL, and sometimes in speed.
IN and EXISTS Side by Side
Both queries below find the customers who have placed at least one order. The IN version builds a list of customer ids from orders and keeps the users whose id is in it. The EXISTS version asks, for each user, "is there an order row for this user?"
With IN
SELECT COUNT(*) AS customers_with_orders
FROM users
WHERE id IN (SELECT user_id FROM orders);
▶ Run this query in the SQL Playground
| customers_with_orders |
|---|
| 114 |
With EXISTS
SELECT COUNT(*) AS customers_with_orders
FROM users u
WHERE EXISTS (SELECT 1
FROM orders o
WHERE o.user_id = u.id);
▶ Run this query in the SQL Playground
| customers_with_orders |
|---|
| 114 |
Both return 114. For a positive check like this they are interchangeable, and most query optimizers turn both into the same plan (a semi-join: keep each outer row that has at least one match, without repeating it).
| IN | EXISTS | |
|---|---|---|
| Asks | Is this value in the list? | Does at least one matching row exist? |
| Subquery returns | One column of values | Anything; the columns are ignored, so SELECT 1 is usual |
| Linked to the outer row | No, the list is the same for every row | Yes, through the WHERE inside the subquery (correlated) |
| Also takes a fixed list | Yes: IN ('shipped', 'delivered') | No |
| NOT version with NULLs | NOT IN returns nothing if the list contains a NULL | NOT EXISTS is not affected |
Syntax
-- IN with a fixed list or a subquery
WHERE column IN (value_1, value_2, ...)
WHERE column IN (SELECT column FROM other_table WHERE ...)
-- EXISTS with a correlated subquery
WHERE EXISTS (SELECT 1
FROM other_table o
WHERE o.key = outer_table.key)
An IN subquery must return exactly one column. An EXISTS subquery can select anything, because only the presence of a row matters; SELECT 1 makes that intent obvious. The condition o.key = outer_table.key is what ties the EXISTS check to the current row. Without it, EXISTS is either true for every row or false for every row.
IN is also the natural choice for a short list of literal values, which EXISTS cannot express:
SELECT status, COUNT(*) AS orders
FROM orders
WHERE status IN ('shipped', 'delivered')
GROUP BY status;
▶ Run this query in the SQL Playground
| status | orders |
|---|---|
| delivered | 82 |
| shipped | 21 |
How Each One Is Evaluated
Logically, IN works out the whole list first and then compares each outer row against it. EXISTS runs its subquery for each outer row and can stop at the first matching row, because one match is enough to make it true. That early stop is called short-circuit evaluation.
You can see the "per row" nature of EXISTS by asking more than one question at once. This labels each of the first users by whether they have ordered and whether they have written a review:
SELECT u.id, u.first_name,
CASE WHEN EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) THEN 'yes' ELSE 'no' END AS has_ordered,
CASE WHEN EXISTS (SELECT 1 FROM reviews r WHERE r.user_id = u.id) THEN 'yes' ELSE 'no' END AS has_reviewed
FROM users u
ORDER BY u.id
LIMIT 6;
▶ Run this query in the SQL Playground
| id | first_name | has_ordered | has_reviewed |
|---|---|---|---|
| 1 | Jennifer | yes | no |
| 2 | Donna | yes | no |
| 3 | Kenneth | no | yes |
| 4 | Michael | yes | no |
| 5 | Robert | yes | yes |
| 6 | Deborah | yes | yes |
The NOT IN NULL Trap
This is the difference that changes results. Find the employees who manage nobody: their id never appears in anyone's manager_id. With NOT IN:
SELECT COUNT(*) AS non_managers
FROM employees
WHERE id NOT IN (SELECT manager_id FROM employees);
▶ Run this query in the SQL Playground
| non_managers |
|---|
| 0 |
The answer is 0, which is wrong. The reason: 10 employees have no manager, so the subquery list contains NULL. id NOT IN (3, 7, NULL) means id <> 3 AND id <> 7 AND id <> NULL, and id <> NULL is never true. So no row passes.
SELECT COUNT(*) - COUNT(manager_id) AS null_manager_ids
FROM employees;
▶ Run this query in the SQL Playground
| null_manager_ids |
|---|
| 10 |
NOT EXISTS asks a different question, "is there an employee whose manager is this one?", and NULLs simply never match:
SELECT COUNT(*) AS non_managers
FROM employees e
WHERE NOT EXISTS (SELECT 1
FROM employees r
WHERE r.manager_id = e.id);
▶ Run this query in the SQL Playground
| non_managers |
|---|
| 110 |
110 employees, the right answer. NOT IN gives the same result only if you filter the NULLs out of the list yourself:
SELECT COUNT(*) AS non_managers
FROM employees
WHERE id NOT IN (SELECT manager_id
FROM employees
WHERE manager_id IS NOT NULL);
▶ Run this query in the SQL Playground
| non_managers |
|---|
| 110 |
Which Is Faster?
There is no rule that one always wins. What usually holds:
| Situation | Usually better | Why |
|---|---|---|
| Short fixed list of values | IN | Simplest to read; nothing to correlate |
| Small subquery result, large outer table | IN or EXISTS, same plan | Modern optimizers rewrite both into a semi-join |
| Large related table with an index on the join column | EXISTS | Each check is an index lookup that stops at the first match |
| "Has no match" checks | NOT EXISTS | Correct with NULLs, and optimizers handle it as an anti-join |
| Need columns from the other table | JOIN | IN and EXISTS only filter |
Whatever the rule of thumb says, the plan on your own data is what counts. Run EXPLAIN (PostgreSQL, MySQL), EXPLAIN QUERY PLAN (SQLite) or look at the execution plan (SQL Server) for both versions. An index on the column the subquery matches on, here orders.user_id, helps both. See indexes.
IN and EXISTS vs JOIN
A JOIN can answer "who has an order?" too, but it returns one row per matching order, so customers with several orders appear several times:
SELECT COUNT(*) AS joined_rows,
COUNT(DISTINCT u.id) AS distinct_customers
FROM users u
JOIN orders o ON o.user_id = u.id;
▶ Run this query in the SQL Playground
| joined_rows | distinct_customers |
|---|---|
| 200 | 114 |
The JOIN produces 200 rows for 114 customers, so you would need DISTINCT to get back to one row each. Use IN or EXISTS when you only need to filter; use a JOIN when you need columns from the other table, such as the order total.
When the question is "how many" rather than "whether", neither IN nor EXISTS is the tool. Count with a LEFT JOIN and GROUP BY:
SELECT u.id, u.first_name,
COUNT(o.id) AS orders
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.first_name
ORDER BY u.id
LIMIT 6;
▶ Run this query in the SQL Playground
| id | first_name | orders |
|---|---|---|
| 1 | Jennifer | 1 |
| 2 | Donna | 1 |
| 3 | Kenneth | 0 |
| 4 | Michael | 3 |
| 5 | Robert | 1 |
| 6 | Deborah | 2 |
Which One Should You Use?
| Question | Use |
|---|---|
| Is the value one of a few fixed values? | IN ('a', 'b', 'c') |
| Does a related row exist? | EXISTS or IN; pick the one your team reads more easily |
| Does no related row exist? | NOT EXISTS |
| Is the value absent from a column that is never NULL? | NOT IN is fine |
| Do you need data from the related table? | JOIN |
| Do you need a count or total per row? | LEFT JOIN with GROUP BY |
Common IN and EXISTS Mistakes
Five errors, most of which run without any error message.
1. NOT IN on a column that contains NULL
SELECT *
FROM employees
WHERE id NOT IN (SELECT manager_id FROM employees);
SELECT *
FROM employees e
WHERE NOT EXISTS (SELECT 1 FROM employees r
WHERE r.manager_id = e.id);
One NULL in the NOT IN list makes the condition unknown for every row, as the NULL trap section shows.
2. EXISTS without the correlation
SELECT *
FROM users u
WHERE EXISTS (SELECT 1 FROM orders);
SELECT *
FROM users u
WHERE EXISTS (SELECT 1 FROM orders o
WHERE o.user_id = u.id);
Without a reference to the outer row, the subquery has the same answer for every row: true if orders has any rows at all.
3. EXISTS where NOT EXISTS was meant
-- "customers who never ordered"
WHERE EXISTS (SELECT 1 FROM orders o
WHERE o.user_id = u.id)
WHERE NOT EXISTS (SELECT 1 FROM orders o
WHERE o.user_id = u.id)
Read the business question again: "has" or "has no". The two give complementary results, so the wrong one looks plausible.
4. Thinking the selected column in EXISTS filters anything
WHERE EXISTS (SELECT total FROM orders o
WHERE o.user_id = u.id)
WHERE EXISTS (SELECT 1 FROM orders o
WHERE o.user_id = u.id
AND o.total > 1000)
EXISTS only looks at whether a row comes back. Any filter on the related rows belongs in the subquery's WHERE.
5. A JOIN plus DISTINCT for a yes/no question
SELECT DISTINCT u.id, u.first_name
FROM users u
JOIN orders o ON o.user_id = u.id;
SELECT u.id, u.first_name
FROM users u
WHERE EXISTS (SELECT 1 FROM orders o
WHERE o.user_id = u.id);
Both are correct, but the JOIN creates one row per order first. EXISTS says what you mean and never duplicates.
IN vs EXISTS Cheat Sheet
| Task | Pattern |
|---|---|
| Value in a fixed list | WHERE status IN ('shipped', 'delivered') |
| Has a related row (IN) | WHERE id IN (SELECT user_id FROM orders) |
| Has a related row (EXISTS) | WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) |
| Has no related row | WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) |
| Safe NOT IN | WHERE id NOT IN (SELECT col FROM t WHERE col IS NOT NULL) |
| Related row with a condition | EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.total > 1000) |
Practice IN vs EXISTS Queries
6 exercises on the real Playground tables.
Write each query yourself first. Every solution has been run against the Playground database.
-
1Easy Count the products that have been ordered at least once, using IN. Table:
products, order_itemsShow solution
SELECT COUNT(*) AS ordered_products FROM products WHERE id IN (SELECT product_id FROM order_items);▶ Run this query in the SQL Playground
The subquery lists every product id that appears in an order line; IN keeps the products in that list.
-
2Easy Answer the same question with EXISTS. Table:
products, order_itemsShow solution
SELECT COUNT(*) AS ordered_products FROM products p WHERE EXISTS (SELECT 1 FROM order_items oi WHERE oi.product_id = p.id);▶ Run this query in the SQL Playground
Same result: the correlated subquery finds at least one order line per product.
-
3Medium List the first 8 products (by id) that have never been reviewed. Table:
products, reviewsShow solution
SELECT p.id, p.name FROM products p WHERE NOT EXISTS (SELECT 1 FROM reviews r WHERE r.product_id = p.id) ORDER BY p.id LIMIT 8;▶ Run this query in the SQL Playground
NOT EXISTS keeps the products with no matching review row.
-
4Medium Count the customers who have at least one order over 4,000. Table:
users, ordersShow solution
SELECT COUNT(*) AS big_spenders FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.total > 4000);▶ Run this query in the SQL Playground
The extra condition on the order goes inside the subquery, next to the correlation.
-
5Medium Count the customers who have ordered but never written a review. Table:
users, orders, reviewsShow solution
SELECT COUNT(*) AS buyers_without_reviews FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) AND NOT EXISTS (SELECT 1 FROM reviews r WHERE r.user_id = u.id);▶ Run this query in the SQL Playground
EXISTS and NOT EXISTS combine with AND like any other conditions.
-
6Hard Show how many orders per status have no payment row at all. Table:
orders, paymentsShow solution
SELECT o.status, COUNT(*) AS orders_without_payment FROM orders o WHERE NOT EXISTS (SELECT 1 FROM payments p WHERE p.order_id = o.id) GROUP BY o.status ORDER BY orders_without_payment DESC;▶ Run this query in the SQL Playground
NOT EXISTS filters the orders first; GROUP BY then counts the remaining ones per status.
📝 Quiz — IN vs EXISTS
Test your understanding · 0/20 answered
What does SQL IN check?
IN compares a value with a fixed list or with the list a subquery returns.
What does SQL EXISTS check?
EXISTS is true if the subquery returns any row and false otherwise. The selected columns are ignored.
For "customers with at least one order", do correct IN and EXISTS queries return the same rows?
In the Playground both return 114 customers.
How is an IN subquery evaluated, logically?
Conceptually the list comes first. Optimizers may still rewrite the query.
How is an EXISTS subquery evaluated, logically?
EXISTS only needs one match, so it can stop early.
Which one is designed to stop at the first match?
EXISTS short-circuits. In practice optimizers often give IN the same plan.
What happens if the NOT IN subquery returns a NULL?
In the Playground, NOT IN against employees.manager_id returns 0 rows for exactly this reason.
Is NOT EXISTS affected by NULLs in the same way?
NOT EXISTS never compares a value with a list, so a NULL cannot poison the result.
For a short fixed list such as a few status values, which is simplest?
EXISTS cannot take a literal list at all; IN ('a', 'b') can.
What does an EXISTS subquery need in order to check each row separately?
Without the correlation the answer is the same for every row.
Which one logically compares against the complete list of subquery values?
IN compares with the list; EXISTS asks per row whether a match exists.
What is a semi-join?
It is the plan most databases use for both IN and EXISTS subqueries.
You need columns from the related table in the result. What do you use?
IN and EXISTS only filter; they cannot return the other table's columns.
How do you find employees who manage nobody, safely with NULLs?
The NOT IN version returns nothing because manager_id contains NULLs.
Where is EXISTS most likely to beat IN?
Then each check is an index lookup that stops at the first match.
Do MySQL, PostgreSQL and SQL Server follow the same logical rules for IN and EXISTS?
The results are defined by the SQL standard; performance depends on each optimizer.
How do you find out which version is faster on your data?
Data size, indexes and the optimizer decide, so check the plan.
Which query finds products that have been ordered, using IN?
IN compares each product id with the list of ordered product ids.
Which query answers the same question with EXISTS?
The correlated subquery looks for at least one order line per product.
Why is IN vs EXISTS a common interview question?
The NOT IN NULL trap in particular is a favourite follow-up question.
IN vs EXISTS Interview Questions
Q1. What is the difference between IN and EXISTS?
IN tests whether a value is in a list, either literal or from a subquery. EXISTS tests whether a correlated subquery returns at least one row. For positive checks they return the same rows; NOT IN and NOT EXISTS differ when NULLs are involved.
Q2. Why can NOT IN return no rows?
x NOT IN (a, b, NULL) expands to x <> a AND x <> b AND x <> NULL. The last comparison is unknown, so the whole condition is never true. Filter the NULLs out of the subquery or use NOT EXISTS.
Q3. Is EXISTS always faster than IN?
No. Modern optimizers usually produce the same semi-join plan for both. EXISTS can be faster on older engines or large correlated checks with a good index; IN is just as fast and clearer for short literal lists. Check the plan.
Q4. Why do people write SELECT 1 inside EXISTS?
Because the selected columns are ignored; only whether a row exists matters. SELECT 1 makes that clear to the reader. SELECT * works the same way.
Q5. When would you use a JOIN instead?
When you need columns from the other table, or a count or total per row (then with GROUP BY). For a pure yes/no filter, IN or EXISTS avoids the duplicate rows a JOIN creates.
Q6. How would you find customers who ordered but never reviewed?
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) AND NOT EXISTS (SELECT 1 FROM reviews r WHERE r.user_id = u.id).
Q7. What is an anti-join?
The opposite of a semi-join: it keeps the outer rows that have no match. NOT EXISTS, and NOT IN on a non-nullable column, are usually executed as anti-joins.
Frequently Asked Questions
What is the difference between IN and EXISTS in SQL?
IN compares a value with a fixed list or a list returned by a subquery. EXISTS checks whether a correlated subquery returns at least one row, without comparing a specific value. They often return the same rows for simple positive checks but work differently.
Which is faster, SQL IN or SQL EXISTS?
For small or fixed lists they usually perform the same, and modern optimizers often give both the same plan. On large related tables EXISTS can be faster because it stops at the first match. Confirm with EXPLAIN on your own database.
Does EXISTS stop at the first match?
Yes. EXISTS only needs to know whether one matching row exists, so the database can stop looking as soon as it finds one.
Does SQL IN handle NULL values safely?
Positive IN does: a NULL in the list simply never matches. NOT IN does not: if the list contains a NULL, NOT IN returns no rows at all. Use NOT EXISTS, or filter the NULLs out of the subquery.
Is EXISTS always faster than IN?
No. For short fixed lists IN is just as fast and easier to read. EXISTS tends to help with correlated checks against large tables, especially with an index on the column it matches on.
Should I use JOIN instead of IN or EXISTS?
Use a JOIN when you need columns from the other table in the result. Use IN or EXISTS when you only need to filter rows by whether a related value or row exists.
What is short-circuit evaluation in SQL EXISTS?
It means EXISTS stops searching the moment it finds one matching row instead of finding every match. That is why EXISTS is efficient for yes/no checks compared with counting all matches.
Which is better for SQL interviews, IN or EXISTS?
Interviewers want to see that you know both: IN for simple list membership, EXISTS and NOT EXISTS for correlated checks and for "has no match" questions where NULLs could appear.
Can IN and EXISTS be used together in the same query?
Yes. A query can combine an IN condition with an EXISTS or NOT EXISTS condition using AND or OR.
Does the choice matter for small practice tables?
Rarely for speed. It still matters for correctness: the NOT IN NULL trap happens on a table of any size, as the Playground example shows.