skip to content

How do you list customers with no orders using a LEFT JOIN and IS NULL?

level: juniorimportance: should knowfreq 80%

answer

  1. keep everyone first, then look for the blanks
  2. the join runs before the filter
  3. unmatched rows carry NULL right-side columns
  4. test the join key with IS NULL

basics

~20 s

LEFT JOIN orders onto customers, then keep only the rows the join failed to match by testing a never-NULL right-side column in the WHERE clause, for example WHERE o.customer_id IS NULL. Those rows are the customers with no order.

solid answer

~50 s

The idiom has two steps. First `LEFT JOIN` the two tables so every customer survives, matched or not. Second, filter the join's output down to the NULL-extended rows: `WHERE o.customer_id IS NULL`. Because a matched row always carries a real value in that column, a `NULL` there can only have been manufactured by the outer join, which means "this customer matched nothing". Two details decide whether it is correct. The column you test must be one that is never `NULL` in the source table — the join key or the primary key — otherwise customers whose orders merely have a `NULL` in that column look unmatched. And the test belongs in `WHERE`, not in `ON`, because it inspects rows the join has already produced. The result holds at most one row per unmatched customer, since NULL-extension emits each of them exactly once, so no `DISTINCT` is needed.

code

sql · 5 lines
sql
-- customers that have never placed an order
SELECT c.id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.customer_id IS NULL;

go deeper

for a junior

Be able to write this query from memory in under a minute and to name which column you test and why. It is one of the most common live-coding screens in any data or backend interview.

for a middle

Explain the two phases — the outer join builds the superset, the WHERE filter keeps only the NULL-extended rows — and justify choosing the join key over a nullable column as the marker.

for a senior

Show the failure mode you have actually seen: an anti-join written against a nullable status or timestamp column that quietly returns records which do have children, and how a row-count reconciliation catches it.

for a principal

Treat this idiom as a data-quality instrument: orphan and never-used checks belong in a scheduled reconciliation, with the column being tested justified in review, so a nullable-column mistake cannot become a silent business number.

## The shape of the pattern ```sql SELECT c.id, c.name FROM customers c LEFT JOIN orders o ON o.customer_id = c.id WHERE o.customer_id IS NULL; ``` Read it as two independent moves. The `LEFT JOIN` guarantees that every customer reaches the intermediate result: those with orders appear once per order, those without appear once with all `orders` columns set to `NULL`. The `WHERE` clause then throws away everything except that second group. What remains is precisely the customers the join could not match — the ones with no orders at all. ## Why the NULL test identifies unmatched rows NULL-extension is the only way a `NULL` can appear in `o.customer_id` here. That column is the join key: for any row the join actually matched, `o.customer_id` equals `c.id` and is therefore a real value. So `o.customer_id IS NULL` is a reliable flag meaning "the outer join invented this half of the row". That reasoning is exactly why the choice of column matters. Suppose you test a genuinely nullable column instead: ```sql -- WRONG: shipped_at is NULL on every unshipped order WHERE o.shipped_at IS NULL ``` Now the filter keeps two different populations: customers with no orders, and customers whose orders simply have not shipped. The query still runs, returns plausible-looking output, and is wrong. Safe columns to test are the join key itself or the right table's primary key, both of which are `NOT NULL` by construction. ## Why the test goes in WHERE `IS NULL` here is a statement about the *joined row*, not about which pairings the join should accept. Putting it in `WHERE` lets the join complete first and then filters its output. That is the whole trick: build the superset, then keep the manufactured rows. ## Duplicates and row counts Beginners often reach for `DISTINCT` out of habit. It is unnecessary. A customer with orders is eliminated by the filter no matter how many orders they have, and a customer without orders is NULL-extended exactly once. So the result carries at most one row per customer already, and adding `DISTINCT` only hides your own understanding of the shape. A quick sanity check that catches most mistakes: if the table has 10 customers and 4 of them have at least one order, the anti-join must return exactly 6 rows. If it returns more, you tested the wrong column or joined on the wrong key. ## Selecting columns from the right side A subtle readability point: never select right-side columns in an anti-join. By construction they are all `NULL` for every returned row, so `SELECT c.name, o.total` produces a column of nothing but `NULL`s and invites the next reader to think something is broken. Project only from the preserved table. ## The same shape solves many requirements Once recognised, the pattern generalises to every "…with none" or "…that never…" requirement: - products never ordered - users who never logged in - departments with no employees - parent records orphaned in a data-migration check It is also directional. To find *orphan* orders — order rows whose customer no longer exists — flip which table is preserved: ```sql SELECT o.id FROM orders o LEFT JOIN customers c ON c.id = o.customer_id WHERE c.id IS NULL; ``` Same idiom, opposite driving table. Being able to switch direction on demand, and to say out loud which side is preserved and which column proves the non-match, is what an interviewer is checking when they ask the classic "customers who never ordered" question. ## What a strong answer sounds like Write the query, then narrate it: "LEFT JOIN keeps every customer; the unmatched ones come back with NULL in the order columns; I filter on the join key because it can never be NULL in a real order row; and no DISTINCT is needed because each unmatched customer is emitted once." That covers the mechanics, the correctness caveat and the row-count reasoning in three sentences.

  • Which right-side column should the IS NULL test name, and why does it matter?
    One that can never be NULL in the source table — the join key or the right table's primary key. A NULL there can only have been manufactured by the outer join. Testing a genuinely nullable column such as `shipped_at` also returns customers whose orders merely have no value in it, which silently inflates the result with rows that do have orders.
  • Do you need DISTINCT on this query?
    No. Customers with orders are removed by the filter regardless of how many orders they have, and each unmatched customer is NULL-extended exactly once, so the result already carries at most one row per customer. Adding `DISTINCT` masks the reasoning and suggests you are unsure what the join produced.
  • How would you flip this to find orders whose customer no longer exists?
    Preserve the other table: `FROM orders o LEFT JOIN customers c ON c.id = o.customer_id WHERE c.id IS NULL`. Same idiom, opposite driving table. This is a standard orphan check after a data migration, where a foreign key was missing or was dropped during the load.

saying these in an interview costs you the question

  • Writes o.customer_id = NULL instead of IS NULL
  • Tests a nullable column, so customers with orders slip through
  • Uses INNER JOIN and still expects unmatched customers
  • Thinks the NULL means the customer row itself is missing
  • Adds DISTINCT without being able to say what duplicated

context