skip to content

Semi- and Anti-Join Patterns

'Rows that have a match' and 'rows that have no match' expressed as EXISTS, IN, an inner join, or LEFT JOIN ... IS NULL — and where the variants stop being equivalent (duplicates, NULLs). Interviewers ask for these rewrites to see if you understand result-set semantics rather than one memorized form.

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

questions

5

How do you list customers that have at least one order without repeating any customer?

level: juniorimportance: must knowfreq 80%

answer

  1. Count the columns you actually need
  2. One output row per matching pair
  3. Existence is a filter, not a combination
  4. EXISTS is a predicate, adds no rows

basics

~20 s

Filter with a semi-join instead of joining: WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id). An inner join emits one output row per matching order, so a customer with five orders appears five times.

solid answer

~50 s

The requirement is a **filter**, not a combination of two tables, so express it as a semi-join. `SELECT c.id, c.name FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id)` returns each qualifying customer exactly once, because `EXISTS` is a predicate that evaluates to TRUE or FALSE for one customer row — it never adds rows. `IN (SELECT customer_id FROM orders)` behaves the same way here. An inner join is different in kind: it pairs each left row with *every* matching right row, so a customer with five orders yields five output rows. That is not corrupt data, it is what a join is defined to do. `SELECT DISTINCT` on top of the join usually patches the symptom, but it de-duplicates the whole projected row rather than restoring the intended semantics, so prefer the semi-join when you only need existence.

code

sql · 9 lines
sql
-- Fan-out: one row per customer-order pair
SELECT c.id, c.name
FROM customers c
JOIN orders o ON o.customer_id = c.id;

-- Semi-join: one row per qualifying customer
SELECT c.id, c.name
FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);

go deeper

for a junior

Be ready to write both versions on a whiteboard and say out loud how many rows each returns for a customer with three orders. Knowing that EXISTS filters and JOIN multiplies is the whole point of the question.

for a middle

Explain the mechanics: a join pairs each left row with every matching right row, while EXISTS evaluates to TRUE or FALSE once per outer row. Mention that IN expresses the same semi-join and that DISTINCT patches the symptom rather than the semantics.

for a senior

Show that you decide by the grain of the required result set, not by which tables are mentioned. Point out that a join plus DISTINCT quietly changes behaviour once right-side columns are projected or once the left table can hold genuine duplicate rows.

for a principal

Frame it as a review habit: DISTINCT scattered through a codebase is usually evidence of accidental fan-out, and fan-out under an aggregate silently inflates sums. Argue for expressing existence checks as semi-joins so the intent survives later edits to the select list.

## The question behind the question "Customers who have placed at least one order" sounds like it needs both tables, and beginners reach for a join because orders are mentioned. But look at the requested result: it lists **customers**, and each customer appears once or not at all. Nothing from `orders` is projected. The orders table is only being consulted to answer a yes/no question per customer. That shape — filter the left table by whether a match exists on the right — is the **semi-join**, and SQL spells it with a predicate, not with a `JOIN` keyword. ## What a join is defined to do `FROM customers c JOIN orders o ON o.customer_id = c.id` conceptually forms every combination of a customer row with an order row and keeps the combinations where the `ON` predicate is TRUE. If customer 1 has five orders, five combinations survive, and the output has five rows whose `c.name` is identical. This row multiplication (sometimes called fan-out) is the entire point of a join — it is how you get order lines next to their customer. It only looks like a bug when you did not want the order columns in the first place. A useful mental check: **the cardinality of an inner join is unbounded by either input** — it can be larger than both tables. The cardinality of a semi-join is bounded: at most one output row per left row. ## Writing the semi-join ```sql SELECT c.id, c.name FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id); ``` `EXISTS` takes a subquery and yields TRUE if that subquery would produce **at least one row**, FALSE otherwise. It never yields UNKNOWN, and it never contributes rows or columns to the outer query. The subquery is correlated: `c.id` inside it refers to the customer row currently being tested. The select list inside `EXISTS` is irrelevant — `SELECT 1`, `SELECT *`, `SELECT NULL` all behave identically, because only the presence of rows matters. `SELECT 1` is the conventional spelling. One thing you must *not* write there is an aggregate: `EXISTS (SELECT COUNT(*) FROM orders o WHERE ...)` is always TRUE, because an unqualified aggregate query always returns exactly one row, even when the count is zero. The uncorrelated `IN` form expresses the same semi-join: ```sql SELECT c.id, c.name FROM customers c WHERE c.id IN (SELECT o.customer_id FROM orders o); ``` It is also duplicate-free: `IN` is a predicate over the current customer row, so a customer whose id appears 500 times in the subquery result still passes the predicate once. Both forms answer the question; which reads better is mostly style, and the negative forms (`NOT IN` vs `NOT EXISTS`) are where the two genuinely diverge. ## Why DISTINCT is a patch, not the fix ```sql SELECT DISTINCT c.id, c.name FROM customers c JOIN orders o ON o.customer_id = c.id; ``` This often returns the right rows, and you will see it everywhere. It is a patch because it changes the answer *after* the fact: the query still builds one row per order and then collapses identical projected rows. Two consequences follow. First, the moment you add a column from `orders` to the select list, the duplicates come back — the rows are no longer identical. Second, `DISTINCT` de-duplicates the whole projected row, so if `customers` legitimately contains two identical rows, the join-plus-`DISTINCT` version returns one and the `EXISTS` version returns two. Write what you mean: existence is a filter. ## Choosing between the two shapes Ask what the output row *is*. If one output row = one customer, you want a semi-join (`EXISTS` / `IN`). If one output row = one order (with its customer's name attached), you want the join, and the repetition of the customer name is correct and expected. If one output row = one customer but you also need a number derived from orders, that is a third shape: aggregate the orders, or use a scalar subquery in the select list. ## What the interviewer is checking They want to see that you reason about the **shape of the result set** rather than pattern-matching "two tables mentioned, therefore JOIN". Saying "the join is fine, I'll add DISTINCT" scores much lower than "the requirement is a filter, so I'll write EXISTS; a join would multiply rows because that is what joins do."

  • Does it matter whether you write SELECT 1, SELECT * or SELECT NULL inside EXISTS?
    No. EXISTS only asks whether the subquery produces at least one row, so its select list is never used as data; SELECT 1 is just the common convention. The one thing to avoid is an aggregate: EXISTS (SELECT COUNT(*) ...) is always TRUE, because an unqualified aggregate query always returns exactly one row even when the count is zero.
  • Would IN (SELECT customer_id FROM orders) give the same rows here?
    Yes. IN is also a predicate evaluated per customer row, so repeated customer_id values in the subquery cannot duplicate a customer. The two forms diverge in the negative case — NOT IN reacts badly to NULLs in the subquery result — and IN needs row-value constructors if the match spans several columns, while EXISTS just ANDs more correlated predicates.
  • How would you return each customer once together with how many orders they placed?
    That is a third shape, not a semi-join: you need a value from orders, so aggregate. Join customers to a grouped derived table (SELECT customer_id, COUNT(*) AS n FROM orders GROUP BY customer_id) on customer_id, or use a scalar subquery in the select list. Keep the grain at one row per customer by aggregating before or during the join.

A join is a mail merge that prints one letter per order; a semi-join is a bouncer at the door who only checks whether you have any order at all before letting you in once.

saying these in an interview costs you the question

  • Claims INNER JOIN and EXISTS always return the same rows
  • Adds SELECT DISTINCT reflexively without asking why rows repeat
  • Thinks EXISTS counts the matching rows rather than testing for any
  • Says the duplicates mean the data is dirty
  • Writes EXISTS (SELECT COUNT(*) ...) and expects it to filter

context

open as a page

Which of NOT EXISTS, NOT IN and LEFT JOIN ... IS NULL correctly returns customers with no orders?

level: middleimportance: must knowfreq 75%

basics

~20 s

All three can express the anti-join, but only NOT EXISTS is unconditionally safe. LEFT JOIN ... WHERE o.customer_id IS NULL is equivalent when the IS NULL test uses the join key and every orders predicate sits in ON. NOT IN breaks if the subquery yields any NULL.

open as a page

Why can EXISTS filter on a related table without returning any of its columns?

level: middleimportance: should knowfreq 55%

basics

~20 s

EXISTS is a predicate, not a table reference: it yields TRUE or FALSE for the current outer row, and its subquery's range variables are out of scope in the outer SELECT list. A semi-join returns only left-side columns, at most one row per left row.

open as a page

How do you find staging rows absent from a target table when the match key spans two columns?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Use NOT EXISTS with one correlated equality per key column, ANDed together. It is the portable multi-column anti-join and extends to any number of columns. A LEFT JOIN with both equalities in ON plus an IS NULL test on a target key column works too.

open as a page

When does adding DISTINCT to a JOIN fail to reproduce the EXISTS semi-join's result?

level: seniorimportance: should knowfreq 45%

basics

~20 s

DISTINCT de-duplicates the whole projected row, so it also collapses rows the left table genuinely holds more than once, while EXISTS preserves left-side multiplicity exactly. It also stops helping as soon as a right-side column is projected, since those values differ per match.

open as a page