skip to content

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