Readineer
PATTERN 8SQL Patterns / Combining Data

SQL EXISTS and NOT EXISTS: finding matching and non-matching rows

Learn how to find rows that do or do not have related records using EXISTS and NOT EXISTS, understand semi-join and anti-join patterns, and avoid multiplying rows when you only need to know whether a match exists.

8 min read10 sections2 predictions
01Foundation

Sometimes you only need to know whether a match exists

In the previous article, we used joins to combine related information from different tables. Consider the same customers and orders tables:

Customers
customer_idcustomer_namecitycountry
1AshaBengaluruIndia
2BenLondonUK
3CarlaDubaiUAE
4DevBengaluruIndia
5EshaSingaporeSingapore
Orders
order_idcustomer_idstatus
10011paid
10022pending
10033paid
10044cancelled
10051paid
10062paid

Suppose the requirement is “return customers who have placed at least one order.” We do not need any columns from orders. We only need to answer one question for each customer: does at least one matching order exist? SQL provides EXISTS for exactly this kind of problem.

02Keeping a row when a match exists

EXISTS: keep a row when a related row exists

Query
SELECT
    c.customer_id,
    c.customer_name
FROM customers AS c
WHERE EXISTS (
    SELECT 1
    FROM orders AS o
    WHERE o.customer_id = c.customer_id
)
ORDER BY c.customer_id;
Result
customer_idcustomer_name
1Asha
2Ben
3Carla
4Dev

Esha does not appear because she has no matching order. For each customer, SQL conceptually asks “does an order exist where customer_id matches?” and keeps the customer when the answer is yes.

Key idea
EXISTS cares whether at least one matching row exists. It does not care how many matches exist.

Why SELECT 1 is common inside EXISTS

The 1 is not being returned as part of the final query result. EXISTS only asks whether the subquery produces at least one row, so SELECT 1 and SELECT * express the same existence test. Using SELECT 1 makes the intention clear: I only care whether a row exists.

The correlation connects the two queries

Look closely at WHERE o.customer_id = c.customer_id. The subquery references o.customer_id from orders and c.customer_id from the outer customers query. This connects the existence test to the customer currently being considered. For Asha, c.customer_id = 1, so the subquery becomes conceptually SELECT 1 FROM orders WHERE customer_id = 1;: rows exist, so Asha qualifies. For Esha, c.customer_id = 5: no row exists, so she does not qualify. This relationship between the inner and outer query is commonly called a correlated subquery.

Adding conditions to the match

Suppose the requirement becomes “return customers who have placed at least one paid order.” The existence condition now has two parts: the order belongs to the customer, and the order is paid.

Query
SELECT
    c.customer_id,
    c.customer_name
FROM customers AS c
WHERE EXISTS (
    SELECT 1
    FROM orders AS o
    WHERE o.customer_id = c.customer_id
      AND o.status = 'paid'
)
ORDER BY c.customer_id;
Result
customer_idcustomer_name
1Asha
2Ben
3Carla

Dev has an order, but it is cancelled. Esha has no order. Neither has a matching paid order. Returning rows from the left side when at least one matching row exists on the right is often described as a semi-join, and EXISTS is one common way to express it.

03The featured failure

Using JOIN for an existence question can multiply rows

Suppose the requirement is return customers who have at least one paid order. Someone writes the query below. The SQL looks reasonable. But what does it return?

PAUSE & PREDICTWhat does this INNER JOIN return?
Query
SELECT
    c.customer_id,
    c.customer_name
FROM customers AS c
INNER JOIN orders AS o
    ON o.customer_id = c.customer_id
WHERE o.status = 'paid'
ORDER BY c.customer_id;
Your prediction

The fix: use EXISTS for an existence question

Write the requirement directly. The outer query contains one row for Asha, and EXISTS only determines whether that row should remain; it does not create another output row for every matching order.

Query
SELECT
    c.customer_id,
    c.customer_name
FROM customers AS c
WHERE EXISTS (
    SELECT 1
    FROM orders AS o
    WHERE o.customer_id = c.customer_id
      AND o.status = 'paid'
)
ORDER BY c.customer_id;
Result
customer_idcustomer_name
1Asha
2Ben
3Carla

Why not just add DISTINCT?

You could write SELECT DISTINCT ... INNER JOIN ... WHERE o.status = 'paid'. This may produce the required output. But if the actual question is “does a matching row exist?”, then EXISTS communicates that requirement more directly. Using JOIN and then DISTINCT means “create all matching combinations, then remove duplicate customer rows,” while using EXISTS means “keep the customer when a match exists.” A useful rule is: if you do not need columns from the matching table and only care whether a match exists, consider EXISTS.

04The opposite question

NOT EXISTS: keep rows when no match exists

Now consider the opposite requirement: “return customers who have never placed an order.” For each customer, we want no matching order to exist.

Query
SELECT
    c.customer_id,
    c.customer_name
FROM customers AS c
WHERE NOT EXISTS (
    SELECT 1
    FROM orders AS o
    WHERE o.customer_id = c.customer_id
)
ORDER BY c.customer_id;
Result
customer_idcustomer_name
5Esha

For Asha, an order exists, so NOT EXISTS is FALSE and she is removed. For Esha, no order exists, so NOT EXISTS is TRUE and she remains. Returning left-side rows only when no matching row exists is commonly called an anti-join.

Finding customers without a particular kind of match

NOT EXISTS becomes especially useful when the absence condition is more specific. “Return customers who have no paid orders” does not mean “customers with no orders”: Dev has an order (cancelled) and should qualify, and Esha qualifies because she has no orders at all.

Query
SELECT
    c.customer_id,
    c.customer_name
FROM customers AS c
WHERE NOT EXISTS (
    SELECT 1
    FROM orders AS o
    WHERE o.customer_id = c.customer_id
      AND o.status = 'paid'
)
ORDER BY c.customer_id;
Result
customer_idcustomer_name
4Dev
5Esha

A subtle difference: “no paid orders” vs “has a non-paid order”

These sound similar but are not the same. “Customers who have no paid orders” (NOT EXISTS … status = 'paid') returns Dev and Esha. “Customers who have at least one order that is not paid” (EXISTS … status <> 'paid') returns Ben and Dev. Ben appears in the second result because he has a pending order, but he does not satisfy “has no paid orders” because he also has a paid order. Esha qualifies for “no paid orders” because she has no paid order at all, but not for “has a non-paid order” because she has no order. “No matching rows exist” is not the same as “at least one opposite row exists.”
05Alternatives and traps

LEFT JOIN … IS NULL, the correlation trap, and NULL

Another common way to find customers without orders is an outer join that keeps rows whose match came back empty. Customers with orders receive matching orders rows; Esha receives order_id = NULL because the LEFT JOIN found no match, so WHERE o.order_id IS NULL keeps only unmatched customers.

Query
SELECT
    c.customer_id,
    c.customer_name
FROM customers AS c
LEFT JOIN orders AS o
    ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;

Both NOT EXISTS and LEFT JOIN … IS NULL can correctly express anti-matching. For pure existence or non-existence questions, EXISTS and NOT EXISTS often make the intent especially clear. Performance can depend on the database, schema, indexes, and optimizer, so choose based primarily on correctness and clarity unless measurement shows a reason to do otherwise.

Gotcha: test a column that reliably indicates a match

Writing LEFT JOIN orders o ON … WHERE o.status IS NULL assumes status = NULL means “no order matched.” But if a real order is allowed to have status = NULL, a matched order could look the same as an unmatched row. Prefer a column that is known to be non-NULL when an actual matching row exists, commonly a primary key: WHERE o.order_id IS NULL.

The featured correlation trap

Consider WHERE EXISTS (SELECT 1 FROM orders AS o WHERE o.status = 'paid'). Notice what is missing: there is no condition connecting orders to the current customer. The subquery simply asks “does any paid order exist anywhere in the orders table?” The answer is yes, so EXISTS is TRUE for every customer, and even Esha appears.

When using a correlated EXISTS or NOT EXISTS, identify the condition that connects the inner rows to the current outer row (o.customer_id = c.customer_id). Without that relationship, you may accidentally test whether a match exists anywhere rather than whether it exists for the current entity.

EXISTS and NULL

One advantage of EXISTS is that it is concerned only with whether the subquery returns rows. A matching order can contain a nullable column such as discount_amount = NULL, and that does not prevent the order row itself from existing. This is different from comparison-based patterns where NULL can introduce UNKNOWN. Another way someone might try to find customers without orders is c.customer_id NOT IN (SELECT o.customer_id FROM orders o). With clean, non-NULL values this may work, but as covered in the NULL article, NOT IN becomes dangerous when the subquery can return NULL: a single NULL can cause otherwise non-matching comparisons to evaluate to UNKNOWN. NOT EXISTS expresses the anti-match directly and is often easier to reason about safely.

Key idea
Semi-joins and anti-joins preserve the left-side grain. EXISTS keeps a customer once when a matching order exists; NOT EXISTS keeps a customer once when none does. Neither multiplies the customer according to the number of matching orders, which is why they fit existence questions so well.
06Recognition cues

When to consider EXISTS or NOT EXISTS

Consider EXISTS or NOT EXISTS when the business requirement contains phrases such as customers who have at least one order, products that have ever been purchased, accounts that have a matching transaction, customers who have no orders, products that have never been sold, or records that do not exist in another dataset. Watch for these common mistakes:

An INNER JOIN multiplies rows when only existence matters.
DISTINCT is added just to undo that multiplication.
An EXISTS subquery is missing its correlation condition.
“No X exists” is incorrectly written as “some non-X exists.”
NOT IN is used even though the subquery may contain NULL.
A LEFT JOIN … IS NULL test uses a nullable right-side column instead of a reliable match key.
07A practical mental model

Start with the business question

1 · Which rows do I want to return?

The outer table, e.g. FROM customers AS c.

2 · Do I need columns from orders?

If yes, you probably need a join. If no, continue.

3 · Existence or absence?

“A matching row exists” → EXISTS; “no matching row exists” → NOT EXISTS.

4 · What counts as a match?

Add the complete condition inside the subquery, correlation included.

Key idea
This separation keeps the query aligned with the requirement.
08Check your understanding

One more prediction

Requirement: return customers who have no paid orders. Which query correctly expresses the requirement?

orders
order_idcustomer_idstatus
10011paid
10022pending
10033paid
10044cancelled
10051paid
10062paid
ONE MORE PREDICTIONWhich query correctly expresses the requirement?
Query
-- Option A
WHERE EXISTS (SELECT 1 FROM orders o
  WHERE o.customer_id = c.customer_id
    AND o.status <> 'paid')

-- Option B
WHERE NOT EXISTS (SELECT 1 FROM orders o
  WHERE o.customer_id = c.customer_id
    AND o.status = 'paid')

-- Option C
INNER JOIN orders o ON o.customer_id = c.customer_id
WHERE o.status <> 'paid'

-- Option D
WHERE NOT EXISTS (SELECT 1 FROM orders o
  WHERE o.status = 'paid')
Your prediction
09Quick check

Lock these in

What does EXISTS check?

Whether the correlated subquery returns at least one row for the current outer row.

Does SELECT 1 or SELECT * matter inside EXISTS?

No. EXISTS only checks whether a row exists; the column list is never used.

What is a semi-join?

Return left-side rows when at least one matching row exists on the right; EXISTS is one way to express it.

What is an anti-join?

Return left-side rows only when no matching row exists; NOT EXISTS expresses it directly.

Why prefer NOT EXISTS over NOT IN?

NOT IN can return no rows if the subquery contains NULL; NOT EXISTS stays safe.

10Summary

Summary

Use EXISTS when the business question is “does at least one matching row exist?” This is a common semi-join pattern. Use NOT EXISTS when the question is “does no matching row exist?” This is a common anti-join pattern. The central difference from a normal join is result shape: a join can produce one output row for every matching combination, while EXISTS and NOT EXISTS keep or remove the outer row based on whether a match exists.

Key idea
The most important correlation rule: the subquery must test existence for the current outer row, not merely whether a matching row exists somewhere. The condition o.customer_id = c.customer_id is what turns a global existence test into a customer-specific one. When the business question contains “at least one” or “none,” think about existence before reaching automatically for a join.

Next, we will learn how to combine complete query results using UNION, UNION ALL, INTERSECT, and EXCEPT.

Go deeper

Ask Colearn about this pattern

Not sure why your existence check duplicates rows or matches everyone? Ask about the concept, or paste a simplified version of your query.

Ask Colearn

3 of 3 free questions left

Instant answers, grounded in the same verified material the diagnostic grades against.

Why does my customer appear multiple times when I only need to know they have an order?

An INNER JOIN produces one result row per matching combination, so a customer with two matching orders appears twice. That answers 'every matching pair', not 'does a match exist'.

For an existence question, use WHERE EXISTS (...): it keeps the outer row once when at least one match exists (a semi-join).

from the unit →
Does SELECT 1 vs SELECT * matter inside EXISTS?

No. EXISTS only asks whether the subquery returns at least one row; the SELECT list is never used. SELECT 1 just makes that intent obvious.

Any column list behaves the same, so pick the one that reads most clearly.

from the unit →
Why is my EXISTS true for every row?

The subquery is missing its correlation to the outer row. Without WHERE o.customer_id = c.customer_id it asks 'does any paid order exist anywhere', which is true for everyone.

Add the condition that ties the inner rows to the current outer row so the test becomes entity-specific.

from the unit →
Should I use NOT EXISTS or NOT IN to find customers without orders?

Prefer NOT EXISTS. NOT IN breaks when the subquery can return NULL: a single NULL makes the comparison UNKNOWN and the query can return no rows.

NOT EXISTS expresses the anti-match directly and stays safe with nullable columns.

from the unit →

That’s the free taste

Two ways to go deeper.

Don’t paste credentials, personal data, or confidential production records.

Related patterns

P07Pattern · 8 minSQL joinsWhen you do need columns from the match, and the LEFT JOIN grain traps.P02Pattern · 8 minNULL & UNKNOWNWhy NOT IN breaks on a NULL, the reason NOT EXISTS is safer.P09Pattern · 7 minSet operationsINTERSECT and EXCEPT compare whole result sets instead of correlating.
Up next · Pattern 9
Set operations: UNION, UNION ALL, INTERSECT, and EXCEPT

Next, we combine complete query results with UNION, UNION ALL, INTERSECT, and EXCEPT.

Continue to P9 →Browse all patterns