Hook: Interviewers love this question because one hidden NULL can make a query look correct and still return the wrong rows.
Question: What is the difference between EXISTS and IN in SQL?
Answer: EXISTS checks whether a subquery returns at least one row, while IN checks whether a value matches one of the returned values. In many databases they can produce the same result, but they behave differently around NULL values and the optimizer may execute them differently. A safe rule is: use EXISTS for existence checks, and be careful with NOT IN because a single NULL can break it.
Interview-Ready Answer: I use EXISTS when I want to know whether related rows exist, and I use IN when I want to compare a value against a list or subquery result. The big gotcha is NULL: NOT IN can return no rows if the subquery contains even one NULL, so in practice I often prefer NOT EXISTS for exclusion logic. Also, modern optimizers often turn both into a semi-join, so the best choice is usually the one that is clearest and safest for the data.
Detailed Explanation: Think of EXISTS as a yes/no test: “Does at least one matching row exist?” Think of IN as a membership test: “Is this value inside this set?” Both are often used in subqueries, but they answer the question in slightly different ways.
EXISTS, the database runs the subquery with that outer row’s values plugged in if the subquery is correlated, meaning it refers back to the outer query.EXISTS is satisfied. It does not need the rest of the rows, so the engine can stop early.IN, the database checks whether the compared value equals one of the values produced by the subquery.NULL, this is straightforward. If the subquery can return NULL, SQL’s three-valued logic kicks in: TRUE, FALSE, or UNKNOWN. That is why NOT IN is dangerous.| Concept | Best for | Main risk |
|---|---|---|
EXISTS | Testing row existence | Can be less obvious if overused |
IN | Comparing to a known set | NULL traps in subqueries |
JOIN | Returning columns from both sides | Duplicate outer rows |
EXISTS for “does a related row exist?” questions, especially with correlated subqueries.IN for small, clean lists or when the subquery is guaranteed not to return NULL.NOT EXISTS instead of NOT IN when excluding rows from another table.JOIN when you need data from the matched table, not just a yes/no answer.In theory, a naive membership test can be expensive if the subquery is scanned again and again. In practice, optimizers usually do much better: they may build a hash set, use an index lookup, or rewrite the query into a semi-join. A good mental model is EXISTS can stop at the first match, while IN may need a complete value set before filtering, but the actual plan depends on the database, indexes, and statistics. On large tables, a well-indexed correlated EXISTS might probe an index millions of times efficiently; without indexes, both forms can degrade toward nested-loop behavior and become slow. Realistically, if your outer table has 5 million rows and the inner table has 200,000 rows, the difference between an indexed semi-join and a full scan can be seconds versus minutes.
NOT IN with a NULL in the subquery can eliminate every row, which surprises many candidates.EXISTS ignores the columns selected inside it, so SELECT 1 and SELECT * are logically equivalent there.IN with duplicates does not change the truth value, but duplicates can still affect how much work the engine does before optimization.IN can be very natural: it behaves like membership in a derived set.Memory-wise, remember this: EXISTS asks “is there at least one seat?” while IN asks “is my ticket number on the guest list?”
Real-World Example: Imagine a checkout service for an e-commerce app that decides whether a customer can use a loyalty coupon. The query checks whether the customer has any completed orders, and a developer writes it with NOT IN against an orders table. One dirty row has a NULL customer id from a bad import, and suddenly the exclusion query returns nothing at all, so every customer looks ineligible. The symptom is brutal: support tickets spike, logs show the coupon-eligibility query returning zero rows, and the UI quietly hides discounts from everyone during a promotion.
The fix is usually to switch to NOT EXISTS and add data-cleanup or a NOT NULL constraint where appropriate. That way the business logic stays correct even if one bad row sneaks into the table.
-- Demonstration of EXISTS vs IN, including the classic NULL edge case.
-- This script is self-contained and can be run in a SQL database that supports
-- standard CTEs and VALUES constructors.
-- Customers we want to test
WITH customers(id, name) AS (
VALUES
(1, 'Ada'),
(2, 'Ben'),
(3, 'Cleo'),
(4, 'Drew')
),
-- Orders includes a NULL customer_id to show the NOT IN trap
orders(order_id, customer_id) AS (
VALUES
(101, 1),
(102, 1),
(103, 2),
(104, NULL)
)
SELECT c.id, c.name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.id
)
ORDER BY c.id;
-- Same business result using IN: customers who have at least one order.
WITH customers(id, name) AS (
VALUES
(1, 'Ada'),
(2, 'Ben'),
(3, 'Cleo'),
(4, 'Drew')
),
orders(order_id, customer_id) AS (
VALUES
(101, 1),
(102, 1),
(103, 2),
(104, NULL)
)
SELECT c.id, c.name
FROM customers c
WHERE c.id IN (
SELECT o.customer_id
FROM orders o
)
ORDER BY c.id;
-- Failure path: NOT IN with a NULL in the subquery produces no rows.
-- This happens because SQL cannot prove that c.id is not equal to NULL.
WITH customers(id, name) AS (
VALUES
(1, 'Ada'),
(2, 'Ben'),
(3, 'Cleo'),
(4, 'Drew')
),
orders(order_id, customer_id) AS (
VALUES
(101, 1),
(102, 1),
(103, 2),
(104, NULL)
)
SELECT c.id, c.name
FROM customers c
WHERE c.id NOT IN (
SELECT o.customer_id
FROM orders o
)
ORDER BY c.id;
-- Safe alternative: NOT EXISTS ignores unrelated NULLs and matches by the join condition.
WITH customers(id, name) AS (
VALUES
(1, 'Ada'),
(2, 'Ben'),
(3, 'Cleo'),
(4, 'Drew')
),
orders(order_id, customer_id) AS (
VALUES
(101, 1),
(102, 1),
(103, 2),
(104, NULL)
)
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.id
)
ORDER BY c.id;Follow-up & Tricky Questions:
EXISTS over a JOIN?EXISTS when you only need to know whether a related row is present. Use a JOIN when you actually need columns from the other table or want to aggregate across matched rows.EXISTS always faster than IN?NOT IN risky?NULL in the subquery can make the predicate evaluate to UNKNOWN for every row, which means you may get no results at all.SELECT 1 inside EXISTS matter?SELECT 1 as a clear convention.EXISTS or IN, but they can affect work done before optimization. The optimizer may remove or ignore them depending on the plan.Tricky gotchas:
WHERE x IN (subquery) the same as many OR conditions?IN into a set lookup, which is usually cleaner and faster than a long chain of ORs.EXISTS return the matching inner row?JOIN or a subquery that selects the columns explicitly.NOT EXISTS the exact opposite of EXISTS?NULL trap that makes NOT IN so error-prone.Common Mistakes:
NOT IN on a nullable subquery. Correction: prefer NOT EXISTS unless you are sure the subquery cannot return NULL.EXISTS needs SELECT *. Correction: the selected columns do not matter; SELECT 1 is enough and clearer.JOIN when only existence is needed. Correction: a JOIN can duplicate rows, while EXISTS naturally answers the yes/no question.Memory Hook: EXISTS = “Is anyone there?” IN = “Is my name on the list?” If you remember that, the NULL trap becomes easier to spot.
Cheat Sheet:
EXISTS checks for at least one matching row.IN checks membership in a set of values.NOT EXISTS is usually safer than NOT IN.NULL can make NOT IN behave unexpectedly.Practice Tasks:
EXISTS.IN and compare the result.NULL into the subquery and observe why NOT IN breaks, then fix it with NOT EXISTS.