performance differences in detail
EXISTS and IN look interchangeable at first glance, but differ in one decisive point: how they handle NULL values in the inner result set. While EXISTS only checks for the existence of a row and NULL plays no role there, a NOT IN with a NULL-containing list can silently return an empty result set, a behavior that regularly leads to missing rows in reports in production, without any error appearing.
Table of Contents
- 1. EXISTS and IN in basic behavior
- 2. Both as a semi-join: the shared ground in the optimizer
- 3. NULL values with EXISTS: no problem
- 4. NULL values with IN: mostly unproblematic
- 5. The NOT IN trap with NULL values
- 6. Why NOT EXISTS is the safe alternative
- 7. Optimizer behavior across databases
- 8. Performance with very large lists and tables
- 9. EXISTS and IN in direct comparison
- 10. Summary
- 11. FAQ
1. EXISTS and IN in basic behavior
EXISTS checks whether a subquery returns at least one row, and evaluates exclusively to TRUE or FALSE, regardless of which specific values the found row contains. IN, on the other hand, compares a specific value of the outer query against a list of values that is either given statically or delivered by a subquery. Both constructs are frequently used for the same business purpose, namely filtering rows based on a relationship to another table, but they differ significantly in their internal logic.
For simple cases without NULL values, EXISTS and IN return identical results. The difference only becomes relevant once either the value list itself can contain NULL entries or negation with NOT comes into play. Exactly these two factors, NULL values and negation, are the core of all practically relevant differences between EXISTS and IN, both regarding correctness and performance.
-- Sample tables
-- customers: id, name
-- orders: id, customer_id, amount
-- IN with a subquery: customers who have placed an order
SELECT c.name
FROM customers c
WHERE c.id IN (SELECT o.customer_id FROM orders o);
-- Semantically equivalent using EXISTS
SELECT c.name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
-- both return the same rows when orders.customer_id has no NULL values
2. Both as a semi-join: the shared ground in the optimizer
Internally, modern databases frequently treat both EXISTS and IN with a subquery as a so-called semi-join, a special join variant that, per outer row, only checks whether at least one matching inner row exists, without actually pulling that row into the result set. A semi-join therefore, unlike a regular join, never returns duplicate rows, even if multiple inner rows match one outer row, and needs no subsequent DISTINCT.
This shared foundation in the execution plan explains why EXISTS and IN show near-identical performance in many cases: PostgreSQL, MySQL from version 8.0 onward, and SQL Server recognize both formulations and produce the same or a very similar execution plan. The performance difference therefore rarely arises from the choice of EXISTS or IN itself, but almost always from missing indexes, very large static lists, or, particularly relevant, from NULL behavior under negation.
3. NULL values with EXISTS: no problem
EXISTS is completely unaffected by NULL values in the inner table, because the subquery only checks whether a row exists at all that satisfies the WHERE condition. Whether one of the returned columns contains NULL plays no role for the TRUE/FALSE result, because EXISTS does not evaluate the values of the inner row at all, only their presence. This property makes EXISTS the most robust choice for existence checks, regardless of the data quality of the referenced column.
Even a subquery that returns a constant value like SELECT 1 as its only column works identically to SELECT o.customer_id with EXISTS, because the specific return value is ignored. This detail is the reason why many style guides consistently recommend SELECT 1 instead of a specific column for EXISTS subqueries, to make it immediately visible that only existence counts.
-- Sample table: orders with a nullable customer_id (edge case)
-- id | customer_id | amount
-- 10 | 1 | 120.00
-- 11 | NULL | 45.00 <- orphaned or not yet assigned order
-- EXISTS is unaffected by the NULL row: it only checks for a match
SELECT c.name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
-- the NULL row simply never matches c.id, EXISTS behaves as expected
4. NULL values with IN: mostly unproblematic
A positive IN, that is without NOT, is largely unproblematic regarding NULL values in the value list. A NULL entry in the list only causes the comparison for that single list value to evaluate to UNKNOWN instead of TRUE or FALSE, which does not affect the overall result of IN as long as at least one other list value actually matches. A NULL value in the list, therefore, does not "hide" matches, it simply contributes no match of its own.
This changes fundamentally as soon as id itself has no matching element in the list: then IN evaluates overall to UNKNOWN, which has the same effect as FALSE in the WHERE clause, so the row is correctly excluded, exactly as expected. Positive IN with NULL values in the list therefore behaves largely intuitively and, as a rule, returns the same result as the corresponding EXISTS.
-- Positive IN with NULL in the list: unaffected in practice
SELECT c.name
FROM customers c
WHERE c.id IN (1, 2, NULL);
-- rows with c.id = 1 or c.id = 2 match normally
-- the NULL entry contributes no extra matches and causes no error
5. The NOT IN trap with NULL values
The practically most important difference between EXISTS and IN shows up under negation: NOT IN with a list that contains even a single NULL value returns an empty result set for the entire query, even if the outer row clearly does not match any of the non-NULL values. The reason lies in SQL's three-valued logic: x NOT IN (1, 2, NULL) is internally evaluated as x <> 1 AND x <> 2 AND x <> NULL, and x <> NULL always yields UNKNOWN, never TRUE. A chain of AND connections where even one link is UNKNOWN can never overall become TRUE, so the overall result of the WHERE condition becomes UNKNOWN or FALSE for every row, never TRUE.
The insidious part of this trap: it does not occur as an error, but as a quiet, silently empty result set. A query that previously reliably returned rows suddenly returns nothing at all after a single NULL row gets inserted into the referenced table, without an error message and without a warning. Especially with subqueries whose underlying table changes, this behavior is one of the most common causes of hard-to-trace data bugs in production systems.
-- Sample table: orders with a nullable customer_id
-- id | customer_id | amount
-- 10 | 1 | 120.00
-- 11 | NULL | 45.00 <- a single NULL breaks NOT IN entirely
-- WRONG: NOT IN with a NULL-containing subquery result returns zero rows
SELECT c.name
FROM customers c
WHERE c.id NOT IN (SELECT o.customer_id FROM orders o);
-- expected: customers with no order at all
-- actual: empty result set, because of the NULL row in orders.customer_id
-- Demonstration with a literal list
SELECT 5 NOT IN (1, 2, NULL);
-- returns UNKNOWN (treated as no match), not TRUE, even though 5 is
-- clearly different from both 1 and 2
6. Why NOT EXISTS is the safe alternative
NOT EXISTS is completely unaffected by the NOT IN trap, because, exactly like EXISTS, it never compares specific inner row values against NULL, but only checks whether a matching row exists or not. A NULL value in orders.customer_id merely causes that single row to be disqualified from the comparison with NOT EXISTS, without affecting the rest of the evaluation. The result therefore remains semantically correct and matches exactly the business expectation "customers with no order at all".
This robustness makes NOT EXISTS the clearly recommended choice for any negation of a relationship check, regardless of whether the referenced column currently contains NULL values or not. The decisive point is: a column that is guaranteed to have no NULL values today can very well have them tomorrow due to a later schema change or a faulty import, and NOT IN then breaks silently, while NOT EXISTS reliably stays correct.
-- RIGHT: NOT EXISTS is immune to NULL values in the referenced column
SELECT c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
-- correctly returns customers with zero orders, NULL rows in orders
-- simply never match and do not affect the outer result
-- Defensive alternative if NOT IN must be kept for readability reasons:
-- explicitly filter out NULLs from the subquery first
SELECT c.name
FROM customers c
WHERE c.id NOT IN (
SELECT o.customer_id FROM orders o WHERE o.customer_id IS NOT NULL
);
-- works correctly, but is easy to forget and adds an extra condition
-- to maintain; NOT EXISTS avoids this maintenance burden entirely
7. Optimizer behavior across databases
PostgreSQL, MySQL, and SQL Server as a rule optimize EXISTS and positive IN with a subquery into an equivalent semi-join execution plan, so the choice between the two formulations usually has no practical relevance for pure read speed. With NOT IN and NOT EXISTS, the picture looks different: some optimizers, especially older MySQL versions, historically handle NOT IN with a subquery worse than NOT EXISTS, because anti-join optimization for NOT IN is more complex to implement due to NULL semantics than for NOT EXISTS.
PostgreSQL has long reliably recognized both forms as an anti-join, provided the referenced column is provably NOT NULL, which the optimizer can derive from the schema. If the column is nullable, however, PostgreSQL cannot turn NOT IN into the same efficient anti-join as NOT EXISTS, because correctness could otherwise no longer be guaranteed, which in practice leads to a measurably worse execution plan for NOT IN. This connection is an additional, purely performance-based argument for NOT EXISTS, independent of the correctness trap already described.
-- Compare the execution plans directly on your target database
EXPLAIN ANALYZE
SELECT c.name FROM customers c
WHERE c.id NOT IN (SELECT o.customer_id FROM orders o);
EXPLAIN ANALYZE
SELECT c.name FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
-- Look specifically for "Anti Join" vs. a plan involving materialization
-- or a sequential scan combined with a filter on the subquery result
8. Performance with very large lists and tables
With a static IN list of literal values, for example IN (1, 2, 3, ..., 10000), a different performance topic arises than with a subquery: the database itself has to parse and index the list, which can cause noticeable overhead with very large lists of several tens of thousands of values. In this case it is often more efficient to load the value list into a temporary table and check against it via JOIN or EXISTS, instead of passing a huge literal list in the SQL text.
With an IN subquery instead of a literal list, performance usually scales well with the size of the referenced table, provided an index exists on the compared column, similar to EXISTS. The decisive performance factor here too is less the choice between EXISTS and IN, and more whether the database can use an index to perform the inner check efficiently, instead of performing a full table scan of the inner table.
9. EXISTS and IN in direct comparison
The table below summarizes the key differences, especially for the critical case of negation with potentially NULL-containing columns.
| Construct | Behavior with NULL in the list | Optimizer handling | Recommendation |
|---|---|---|---|
| EXISTS | Unaffected | Semi-join, reliably optimized | First choice for existence checks |
| IN | Largely unproblematic | Semi-join, mostly like EXISTS | Good readability for filter lists |
| NOT EXISTS | Unaffected | Anti-join, reliably optimized | Recommended for any negation |
| NOT IN | Critical, returns empty set | Often less efficient anti-join | Avoid with nullable columns |
This overview makes the core of the topic visible: the performance difference between EXISTS and IN is usually small in practice, while the correctness difference between NOT EXISTS and NOT IN with nullable columns is substantial and potentially serious.
Mironsoft
Data quality, query audits and SQL safety reviews
Find silent NOT IN bugs in existing SQL code?
We systematically review existing queries for NOT IN cases with nullable columns, rewrite them into safe NOT EXISTS, and validate the results against the original data.
Code audit
Systematic search for risky NOT IN patterns across the codebase
Safe rewrite
Replace NOT IN with NOT EXISTS without changing business behavior
Regression check
Automatically compare result sets before and after the rewrite
10. Summary
The performance difference between EXISTS and positive IN is usually small in modern databases, because both are reliably optimized as a semi-join. The actually critical difference lies in negation: NOT IN with a list that contains NULL values silently returns an empty result set due to SQL's three-valued logic, while NOT EXISTS is completely unaffected by this problem, because it never compares values against NULL, only checks the existence of a row.
This combination of correctness risk and sometimes worse optimization makes NOT EXISTS the clearly recommended choice for any negation of a relationship check, regardless of whether the referenced column is currently guaranteed to have no NULL values. A column can change, a NOT EXISTS stays correct in every case.
EXISTS vs. IN, the essentials at a glance
EXISTS and positive IN
Usually identical performance, both reliably optimized as a semi-join. NULL values are unproblematic.
NOT IN with NULL
A single NULL value in the list makes the entire query silently return nothing, without an error message.
NOT EXISTS
Immune to NULL values, because it only checks the existence of a row, never a value comparison with NULL.
Recommendation
Consistently use NOT EXISTS instead of NOT IN for any negation, regardless of the current data state.