When does adding DISTINCT to a JOIN fail to reproduce the EXISTS semi-join's result?
answer
- DISTINCT acts on the projection, EXISTS on the row
- What if the left table already repeats itself?
- Add one right-side column and watch it fail
- Two NULLs are duplicates here, but never equal there
basics
~20 sDISTINCT 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.
solid answer
~50 s`SELECT DISTINCT l.* FROM l JOIN r ON ...` reproduces `WHERE EXISTS (...)` only when the projected rows are unique in the left table to begin with — typically when you project a key. Two cases break it. First, **genuine left-side duplicates**: if a staging table holds the same row twice and both match, `EXISTS` returns two rows and `DISTINCT` returns one. `DISTINCT` cannot tell an accidental duplicate created by fan-out from a real one that was always there. Second, **projecting right-side columns**: once `r.id` or `r.amount` is in the select list, the fanned-out rows are no longer identical, so `DISTINCT` removes nothing and every match reappears. There is also a NULL nuance: `DISTINCT` treats NULLs as duplicates of one another, so it collapses rows that `=` would never have matched. The safe rewrite is to move the existence test into a predicate — `EXISTS` — and leave the select list alone.
code
sql · 11 lines-- Same input, different answers when the left table repeats a row
-- staging_emails: '[email protected]' twice blocklist: '[email protected]' once
SELECT DISTINCT s.email
FROM staging_emails s
JOIN blocklist b ON b.email = s.email; -- 1 row
SELECT s.email
FROM staging_emails s
WHERE EXISTS (SELECT 1 FROM blocklist b
WHERE b.email = s.email); -- 2 rowsgo deeper
Know that DISTINCT removes duplicate rows from the final result and that people often use it to hide duplicates created by a join. Being able to say why that is a patch is already good at this level.
Explain that DISTINCT operates on the whole projected row after the join, so it does nothing once a right-side column is selected, and that it also collapses duplicates the left table genuinely contained.
Demonstrate the diagnosis on real data: compare counts against the EXISTS form, identify inputs without a declared key, and describe how a later select-list edit silently changes the grain of a DISTINCT-based report.
Treat scattered SELECT DISTINCT as a systemic signal that result grain is not stated anywhere. Push for queries whose grain is explicit and for keys on staging inputs, so filtering and de-duplication stop being conflated across the codebase.
## Two operations that look alike `EXISTS` filters. `DISTINCT` de-duplicates. On a well-behaved schema — join a keyed left table to a child table and project only the key — they happen to produce the same rows, which is why `SELECT DISTINCT` over a join is such a common substitute for a semi-join. The equivalence is a coincidence of the data, not a property of the operators, and a senior candidate is expected to know where the coincidence ends. ## Case 1: the left table has real duplicates Semi-join semantics preserve the left input's multiplicity: if a left row appears three times and qualifies, three rows come out. `DISTINCT` removes multiplicity unconditionally. ```sql -- staging_emails: the same address landed twice from two files -- blocklist: one row for that address SELECT DISTINCT s.email FROM staging_emails s JOIN blocklist b ON b.email = s.email; -- 1 row SELECT s.email FROM staging_emails s WHERE EXISTS (SELECT 1 FROM blocklist b WHERE b.email = s.email); -- 2 rows ``` Which is right depends on the question, and that is the point: if you are counting how many staging rows must be rejected, the `DISTINCT` version under-reports and no error is raised. Tables without a declared key — staging areas, event logs, imported files, and any projection that omits the key — are where this bites. ## Case 2: the select list reaches the right side `DISTINCT` applies to the entire select list, after the join. Add anything that varies per match and it has nothing to collapse: ```sql -- Intent: customers who ordered, once each. Actual: one row per order. SELECT DISTINCT c.id, c.name, o.id FROM customers c JOIN orders o ON o.customer_id = c.id; ``` This is a common regression path: the query was correct when written, someone later added a column "just for debugging", and the row count silently multiplies. With `EXISTS`, adding a left-side column to the select list cannot change the row count, because the filter lives in `WHERE` and does not depend on the projection. The `EXISTS` form is robust against the edit; the `DISTINCT` form is not. ## Case 3: NULL handling inside DISTINCT `DISTINCT` compares rows treating two NULLs as the same value, so `(1, NULL)` and `(1, NULL)` collapse to one row. Equality in a join predicate does the opposite: `NULL = NULL` is UNKNOWN and never matches. So the two operators disagree about NULLs in opposite directions. When the projected columns are nullable, reason about it explicitly rather than assuming `DISTINCT` is a no-op on unique data. ## Case 4: what you can de-duplicate at all De-duplication requires comparing whole rows, so every projected column must be comparable for equality. Engines differ in which column types they will de-duplicate, and some large or structured types cannot participate. `SELECT DISTINCT big_document_column, ...` may therefore be rejected or expensive where the `EXISTS` version has no such requirement — check your engine before relying on it. ## The mechanical rewrite Whenever you see `SELECT DISTINCT` over a join, ask why the duplicates exist: - **Fan-out from a right table whose columns are not projected** — the existence test belongs in `WHERE` as `EXISTS`; delete the join and the `DISTINCT`. - **Duplicates already present in the left input** — `DISTINCT` may be exactly right, but say so deliberately. - **You want one specific matching row per left row** — neither operator expresses that; that is a top-N-per-group problem. `GROUP BY` on the left key is not a better patch than `DISTINCT`: it collapses the same rows and forces every projected column into the grouping list or into an aggregate. ## Why interviewers like this question `SELECT DISTINCT` is the single most common cover-up in production SQL. Asking when it fails to match `EXISTS` separates candidates who treat it as a formatting habit from those who can state precisely what it changes: it alters the multiset of the result rather than the filter, and altering the multiset is only harmless when the multiset was already what you wanted.
- What is the mechanical rewrite that is always safe?Move the existence test out of FROM and into WHERE: drop the join and the DISTINCT, and write WHERE EXISTS (SELECT 1 FROM r WHERE r.k = l.k). The projection is then independent of the filter, so left-side multiplicity is preserved and adding columns later cannot change the row count.
- Would GROUP BY on the left key be a better patch than DISTINCT?No. It collapses the same rows for the same reason, so it inherits both problems, and it additionally forces every projected column into the GROUP BY list or under an aggregate. If the goal is only to filter, neither belongs in the query — grouping should be reserved for when you actually need aggregated values.
- How would you detect this problem in an existing report?Compare row counts between the join-with-DISTINCT version and the EXISTS version on real data; a difference means the left input holds genuine duplicates. Also check whether any projected column comes from the joined table — if so, the DISTINCT is de-duplicating nothing and the report is at order grain, not customer grain.
saying these in an interview costs you the question
- Treats DISTINCT as the standard fix for join duplicates
- Assumes DISTINCT restores semi-join semantics in all cases
- Forgets that adding a right-side column defeats DISTINCT entirely
- Says DISTINCT never affects a query that returns unique rows
- Confuses de-duplicating the projection with filtering rows