SQLab Hub
Lesson 22 · IN vs EXISTS
Lesson 22 · Subqueries

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.

∈ vs ∃ ▶ Runnable Playground examples 🧪 6 practice exercises 📝 20-question quiz ⏱ ~25 min

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

SQLite · runs in the Playground
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 an order, using IN
customers_with_orders
114
✓ 1 row · real output from the Playground database

With EXISTS

SQLite · runs in the Playground
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 an order, using EXISTS
customers_with_orders
114
✓ 1 row · real output from the Playground database

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).

INEXISTS
AsksIs this value in the list?Does at least one matching row exist?
Subquery returnsOne column of valuesAnything; the columns are ignored, so SELECT 1 is usual
Linked to the outer rowNo, the list is the same for every rowYes, through the WHERE inside the subquery (correlated)
Also takes a fixed listYes: IN ('shipped', 'delivered')No
NOT version with NULLsNOT IN returns nothing if the list contains a NULLNOT EXISTS is not affected

Syntax

SQL · 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:

SQLite · runs in the Playground
SELECT status, COUNT(*) AS orders
FROM orders
WHERE status IN ('shipped', 'delivered')
GROUP BY status;

▶ Run this query in the SQL Playground

IN with a fixed list
statusorders
delivered82
shipped21
✓ 2 rows · real output from the Playground database

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:

SQLite · runs in the Playground
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

Users flagged by whether they ordered and reviewed
idfirst_namehas_orderedhas_reviewed
1Jenniferyesno
2Donnayesno
3Kennethnoyes
4Michaelyesno
5Robertyesyes
6Deborahyesyes
✓ 6 rows · real output from the Playground database
In practice the optimizer decides. PostgreSQL, SQL Server, Oracle and MySQL 8 usually rewrite both IN and EXISTS subqueries into a semi-join and pick the same plan. The textbook difference matters most on older MySQL versions and on queries complex enough that the rewrite does not happen.

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:

SQLite · runs in the Playground
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 found with NOT IN
non_managers
0
✓ 1 row · real output from the Playground database

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.

SQLite · runs in the Playground
SELECT COUNT(*) - COUNT(manager_id) AS null_manager_ids
FROM employees;

▶ Run this query in the SQL Playground

NULLs in the manager_id column
null_manager_ids
10
✓ 1 row · real output from the Playground database

NOT EXISTS asks a different question, "is there an employee whose manager is this one?", and NULLs simply never match:

SQLite · runs in the Playground
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 found with NOT EXISTS
non_managers
110
✓ 1 row · real output from the Playground database

110 employees, the right answer. NOT IN gives the same result only if you filter the NULLs out of the list yourself:

SQLite · runs in the Playground
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

NOT IN with the NULLs removed
non_managers
110
✓ 1 row · real output from the Playground database
Rule of thumb. Positive IN is safe with NULLs; a NULL in the list just never matches. NOT IN is only safe when the subquery column cannot be NULL. If you are not sure, use NOT EXISTS. See IS NULL for why comparisons with NULL are never true.

Which Is Faster?

There is no rule that one always wins. What usually holds:

SituationUsually betterWhy
Short fixed list of valuesINSimplest to read; nothing to correlate
Small subquery result, large outer tableIN or EXISTS, same planModern optimizers rewrite both into a semi-join
Large related table with an index on the join columnEXISTSEach check is an index lookup that stops at the first match
"Has no match" checksNOT EXISTSCorrect with NULLs, and optimizers handle it as an anti-join
Need columns from the other tableJOININ 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:

SQLite · runs in the Playground
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

Rows produced by a JOIN to orders
joined_rowsdistinct_customers
200114
✓ 1 row · real output from the Playground database

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:

SQLite · runs in the Playground
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

Orders per customer, including zero
idfirst_nameorders
1Jennifer1
2Donna1
3Kenneth0
4Michael3
5Robert1
6Deborah2
✓ 6 rows · real output from the Playground database

Which One Should You Use?

QuestionUse
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

❌ Returns 0 rows in the Playground
SQL
SELECT *
FROM employees
WHERE id NOT IN (SELECT manager_id FROM employees);
✅ Returns the 110 non-managers
SQL
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

❌ True for every user
SQL
SELECT *
FROM users u
WHERE EXISTS (SELECT 1 FROM orders);
✅ Checks each user
SQL
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 with orders
SQL
-- "customers who never ordered"
WHERE EXISTS (SELECT 1 FROM orders o
              WHERE o.user_id = u.id)
✅ Customers without orders
SQL
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

❌ The total is ignored
SQL
WHERE EXISTS (SELECT total FROM orders o
              WHERE o.user_id = u.id)
✅ Put the condition in WHERE
SQL
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

❌ Duplicates, then removes them
SQL
SELECT DISTINCT u.id, u.first_name
FROM users u
JOIN orders o ON o.user_id = u.id;
✅ Filters directly
SQL
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

TaskPattern
Value in a fixed listWHERE 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 rowWHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)
Safe NOT INWHERE id NOT IN (SELECT col FROM t WHERE col IS NOT NULL)
Related row with a conditionEXISTS (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.

  1. 1Easy Count the products that have been ordered at least once, using IN. Table: products, order_items

    Show solution
    SQLite · runs in the Playground
    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.

  2. 2Easy Answer the same question with EXISTS. Table: products, order_items

    Show solution
    SQLite · runs in the Playground
    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.

  3. 3Medium List the first 8 products (by id) that have never been reviewed. Table: products, reviews

    Show solution
    SQLite · runs in the Playground
    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.

  4. 4Medium Count the customers who have at least one order over 4,000. Table: users, orders

    Show solution
    SQLite · runs in the Playground
    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.

  5. 5Medium Count the customers who have ordered but never written a review. Table: users, orders, reviews

    Show solution
    SQLite · runs in the Playground
    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.

  6. 6Hard Show how many orders per status have no payment row at all. Table: orders, payments

    Show solution
    SQLite · runs in the Playground
    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

Question 1 of 20

What does SQL IN check?

Question 2 of 20

What does SQL EXISTS check?

Question 3 of 20

For "customers with at least one order", do correct IN and EXISTS queries return the same rows?

Question 4 of 20

How is an IN subquery evaluated, logically?

Question 5 of 20

How is an EXISTS subquery evaluated, logically?

Question 6 of 20

Which one is designed to stop at the first match?

Question 7 of 20

What happens if the NOT IN subquery returns a NULL?

Question 8 of 20

Is NOT EXISTS affected by NULLs in the same way?

Question 9 of 20

For a short fixed list such as a few status values, which is simplest?

Question 10 of 20

What does an EXISTS subquery need in order to check each row separately?

Question 11 of 20

Which one logically compares against the complete list of subquery values?

Question 12 of 20

What is a semi-join?

Question 13 of 20

You need columns from the related table in the result. What do you use?

Question 14 of 20

How do you find employees who manage nobody, safely with NULLs?

Question 15 of 20

Where is EXISTS most likely to beat IN?

Question 16 of 20

Do MySQL, PostgreSQL and SQL Server follow the same logical rules for IN and EXISTS?

Question 17 of 20

How do you find out which version is faster on your data?

Question 18 of 20

Which query finds products that have been ordered, using IN?

Question 19 of 20

Which query answers the same question with EXISTS?

Question 20 of 20

Why is IN vs EXISTS a common interview 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.