skip to content

EXISTS vs IN vs JOIN

Three ways to ask 'does a matching row exist' behave very differently at scale. You learn which idiom to reach for, when EXISTS short-circuits ahead of IN, and when JOIN+DISTINCT pays for deduplication the other forms never needed.

part ofSQLoverview, primer and where to startread it →
on this pageshow

questions

4

Which of EXISTS, IN, or INNER JOIN with DISTINCT scales best for filtering customers that have orders?

level: middleimportance: must knowfreq 72%

answer

  1. you only need a yes or no answer
  2. one output row per outer row
  3. the join makes you dedup afterwards
  4. a semi-join can stop at the first match
  5. DISTINCT sorts or hashes the whole result

basics

~20 s

EXISTS and IN both express a semi-join: each outer row is kept once, as soon as one match is found. INNER JOIN emits every matching pair, so it needs a DISTINCT that sorts or hashes the whole result afterwards.

solid answer

~50 s

All three answer "does a related row exist", but only the join generates rows it then has to throw away. `WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id)` keeps a customer once and can stop at the first matching order, so with an index on `orders(customer_id)` it is one probe per candidate. `WHERE c.customer_id IN (SELECT o.customer_id FROM orders o)` is the same semi-join written uncorrelated; duplicates inside the subquery are irrelevant because `IN` is a membership test. `JOIN orders` instead multiplies each customer by its order count, and the `SELECT DISTINCT` you must add is a blocking sort or hash over the full select list — real CPU and memory that the other two never spend. So default to EXISTS (or IN) when you only need to filter, and reach for the join when you actually need columns from `orders`.

code

sql · 6 lines
sql
-- Semi-join: one row per customer, nothing to deduplicate
SELECT c.customer_id, c.name
FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);

go deeper

for a junior

Know the three spellings and be able to write the EXISTS form correctly, including the correlation predicate linking the subquery to the outer row. Recall that a join to a child table can return the same parent many times.

for a middle

Explain the mechanics: EXISTS and IN are semi-joins that yield one row per outer row, while the join emits every pair and then needs a blocking DISTINCT. Be able to say what that DISTINCT costs and when it is unnecessary.

for a senior

Show the judgment: pick the idiom from the data shape, know that the child join key must be indexed, and read a plan to confirm the engine chose a semi-join rather than a join followed by a dedup. Recognise a stray DISTINCT as a design smell.

for a principal

Frame it as a review standard rather than a trick: a DISTINCT that exists only to undo a join is a defect the team should catch in review, and a codebase full of them signals people are reaching for joins by reflex when they only mean to filter.

## The shape of the problem A huge family of everyday queries has the form "rows from A that have at least one related row in B": customers with orders, articles with comments, users with a failed login today. In relational terms that is a **semi-join** — every output column comes from A, and B is consulted only as a test. SQL offers three common spellings, and they differ far more in the work they generate than in the text you type. ## Spelling 1: EXISTS ```sql SELECT c.customer_id, c.name FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id); ``` `EXISTS` is a predicate over a subquery: it is TRUE when the subquery produces at least one row. Two consequences follow. First, the subquery's select list is never evaluated for its values — `SELECT 1`, `SELECT *` and `SELECT o.total` mean exactly the same thing, and no column value flows to the outer query. Second, one qualifying row settles the question, so the engine has no reason to look for the second, third or thousandth match. With an index on `orders(customer_id)` that is a single index probe per candidate customer. The output is at most one row per customer, so nothing downstream has to remove duplicates. ## Spelling 2: IN with a subquery ```sql SELECT c.customer_id, c.name FROM customers c WHERE c.customer_id IN (SELECT o.customer_id FROM orders o); ``` `IN` is a membership test. Duplicates in the subquery result change nothing: a customer with 500 orders passes the test once and appears once. Semantically this is the same semi-join as the EXISTS form, and cost-based optimizers commonly compile both into the same physical semi-join — which is why "EXISTS is always faster than IN" is folklore rather than a rule you should repeat without reading a plan. What the spellings genuinely differ in is the *shape* they hand the planner. The correlated EXISTS suggests a per-outer-row probe into `orders`; the uncorrelated IN suggests building one structure over the distinct order keys and probing it with customers. Which is cheaper depends on which side is small, how selective the other filters are, and which key is indexed. ## Spelling 3: JOIN plus DISTINCT ```sql SELECT DISTINCT c.customer_id, c.name FROM customers c JOIN orders o ON o.customer_id = c.customer_id; ``` A join is not a membership test — it pairs rows. If the average customer has k orders, the join emits roughly N x k rows, and the only reason the query still "works" is the `DISTINCT` collapsing them back down. That `DISTINCT` is a blocking operator: it must sort or hash the entire intermediate result at the full width of the select list before it can emit anything, and it can spill to temporary storage when the result does not fit its memory budget. You paid to build rows you then paid again to destroy. Widen the select list with a few `TEXT` columns and the dedup gets proportionally worse. It is also a blunt instrument: `DISTINCT` compares the whole select list, so it silently collapses parent rows that are legitimately identical, not just the copies the join manufactured. ## The cost profile side by side - **EXISTS**: work is proportional to the number of outer rows tested, times the cost of one probe. Output rows: at most one per outer row. No dedup. - **IN (subquery)**: same result; typically one pass over the inner keys plus a probe per outer row, or the same per-row probe if the planner correlates it. No dedup. - **JOIN + DISTINCT**: work is proportional to the number of matching *pairs*, plus a full sort or hash of that intermediate at projected row width. When k is 1, all three are close. When k is 500, the join does two orders of magnitude more work for an identical answer. ## When the join is still the right idiom Reach for the join when you need something from the child table — an order date, an amount, a count. A semi-join deliberately gives you nothing from B, so if the report shows order columns, EXISTS is the wrong tool. The join is also the natural spelling when the join key is unique on the child side (a one-to-one relationship, or a foreign key pointing at a unique key): there is no fan-out, so there is nothing to deduplicate and no `DISTINCT` belongs in the query at all. And if you need aggregates from B, aggregate in a derived table first and join to that, so the fan-out never reaches the outer select list. ## What to check before you commit Make sure the child side's join key is indexed — every one of these idioms depends on it. Then read the plan for the query you actually wrote: confirm the engine chose a semi-join and that no sort or hash-aggregate appeared to clean up after you. If a `DISTINCT` shows up in a plan for a query whose output should already be unique, that is your signal that a join is doing a semi-join's job.

  • Does writing SELECT 1 instead of SELECT * inside EXISTS make the query faster?
    No. `EXISTS` is TRUE when the subquery returns at least one row, so its select list is never evaluated for values. `SELECT 1`, `SELECT *` and `SELECT o.total` are equivalent to the engine. `SELECT 1` is a readability convention that signals "the columns do not matter here", nothing more.
  • You need the customer's name and their most recent order date. Does EXISTS still fit?
    No — a semi-join returns nothing from the child table. Either join to a derived table that already reduced orders to one row per customer (a MAX per customer), or use a scalar subquery in the select list. The point is to reduce the child side to one row per parent before it reaches the outer query, so no dedup is needed.
  • When is INNER JOIN with no DISTINCT a perfectly correct existence filter?
    When the join key is unique on the child side — a one-to-one relationship, or a foreign key referencing a unique key. Each parent then matches at most one child row, the join produces no fan-out, and adding `DISTINCT` would only buy you a pointless sort.

Checking a guest list is not the same as pairing everyone up: EXISTS asks the doorman "is this name on the list?" and moves on, while the join writes down every ticket the guest ever bought and then crosses out the repeats.

saying these in an interview costs you the question

  • EXISTS is always faster than IN, on every engine
  • DISTINCT is free, it just tidies up the output
  • SELECT * inside EXISTS makes the subquery read more data
  • All three forms always compile to the same plan
  • Joins are set-based, so a join always beats a subquery

context

open as a page

Why prefer EXISTS over comparing SELECT COUNT(*) to zero when testing whether a matching row exists?

level: juniorimportance: should knowfreq 48%

basics

~20 s

COUNT must visit every matching row to produce a total; EXISTS only has to find one and can stop there. Both answer the same yes/no question, but COUNT's work grows with the number of matches while EXISTS's does not.

open as a page

Is EXISTS always faster than IN with a subquery, or is that rule of thumb outdated?

level: middleimportance: should knowfreq 60%

basics

~20 s

It is outdated as a blanket rule. For an existence filter both express the same semi-join, and cost-based optimizers commonly produce the same plan for either. The honest answer is that the shape you write is a hint, and the plan decides.

open as a page

A report uses INNER JOIN plus SELECT DISTINCT only to filter — what does that cost at scale?

level: seniorimportance: should knowfreq 52%

basics

~20 s

The join multiplies each parent row by its number of matching children, and DISTINCT then sorts or hashes that whole intermediate at full row width to undo it. You pay to build rows and pay again to discard them; a semi-join never builds them.

open as a page