EXISTS vs. IN: Performance Differences in Detail
AI generated
SELECT
JOIN
SQL · Databases · EXISTS vs. IN
EXISTS vs. IN
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.

14 min read EXISTS · IN · NOT IN · NULL Handling Standard SQL · MySQL · PostgreSQL · SQL Server

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.

11. FAQ: EXISTS vs. IN Performance Differences

1Is EXISTS fundamentally faster?
No, usually identical performance thanks to semi-join optimization in both cases.
2NOT IN returns no results?
Probably a NULL value in the referenced column, that makes NOT IN evaluate to empty.
3UNKNOWN vs. FALSE?
UNKNOWN arises from NULL comparisons and acts like FALSE in WHERE. With AND, one UNKNOWN prevents a TRUE result.
4Is positive IN also affected?
No, NULL in the list only contributes no match of its own, does not affect other matches.
5Avoid NOT IN altogether?
Yes for potentially nullable columns, NOT EXISTS is the immune, equivalent alternative.
6Make NOT IN safe without switching?
With an IS NOT NULL filter in the subquery, but easy to forget. NOT EXISTS structurally avoids that risk.
7All databases equally affected?
NULL semantics are standard SQL and apply everywhere the same, only optimizer behavior differs.
8What is an anti-join?
The execution plan for negations, returns rows without a matching counterpart in the inner table.
9Is EXISTS more readable than IN?
Often yes for pure existence checks, usually equivalent for simple static lists.
10Check NULL values in a column?
COUNT with WHERE column IS NULL, additionally check schema for a NOT NULL constraint.