skip to content

How would you use GROUP BY and HAVING to find duplicate email addresses in a users table?

level: juniorimportance: must knowfreq 78%

answer

  1. Duplicates are a property of a group, not a row
  2. Start by grouping on the column in question
  3. The count has to be tested after grouping
  4. COUNT(*) > 1 goes in a clause WHERE cannot host

basics

~20 s

Group by the column and keep only the groups with more than one row: SELECT email, COUNT() FROM users GROUP BY email HAVING COUNT() > 1. WHERE cannot express this, because the count exists only after grouping.

solid answer

~50 s

Group by the candidate key and test the group size with HAVING: ```sql SELECT email, COUNT(*) AS occurrences FROM users GROUP BY email HAVING COUNT(*) > 1 ORDER BY occurrences DESC; ``` `GROUP BY email` produces one group per distinct value; `COUNT(*)` is that group's row count; `HAVING COUNT(*) > 1` keeps only the values that occur more than once. The same predicate cannot be written in `WHERE`, which sees one row at a time and has no notion of how many siblings that row has. For a composite duplicate rule, group by every column of the rule — `GROUP BY tenant_id, LOWER(email)` — since the group key defines what "the same" means. The query returns the duplicated *values*, not the duplicate rows themselves; getting the offending rows means joining back to the table or using a row-numbering approach.

code

sql · 6 lines
sql
SELECT email, COUNT(*) AS occurrences
FROM users
WHERE email IS NOT NULL
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY occurrences DESC, email;

go deeper

for a junior

You should be able to type this query from memory in a screen: group by the column, count, then HAVING COUNT(*) > 1. Know that the count cannot be tested in WHERE.

for a middle

Explain that the GROUP BY key is the definition of duplication, adapt it to a composite or normalised key such as LOWER(email), and know that the result lists duplicated values rather than duplicate rows.

for a senior

Talk about using this as a data-quality probe before adding a uniqueness rule: normalise the key the same way the application does, decide what NULL means in that data, and plan how you will reach the underlying rows to resolve them.

for a principal

Frame duplicate detection as a symptom rather than a task: an ad-hoc HAVING query is how you measure the damage, while preventing recurrence is a question of where sameness is defined and enforced in the system.

## The shape of the idiom "Find the duplicates" is the single most common practical use of `HAVING`, and it is a two-step thought: define what "the same" means, then keep the groups that occur more than once. ```sql SELECT email, COUNT(*) AS occurrences FROM users GROUP BY email HAVING COUNT(*) > 1 ORDER BY occurrences DESC, email; ``` `GROUP BY email` collapses the table into one group per distinct email value. `COUNT(*)` counts the rows in each group. `HAVING COUNT(*) > 1` discards every group of size one, leaving exactly the values that appear more than once, together with how often. ## Why WHERE cannot do this `WHERE` is evaluated per row, before `GROUP BY` runs. When a given row is being tested, the engine has not counted anything; there is no group and therefore no `COUNT(*)` to compare against. `WHERE COUNT(*) > 1` is rejected outright by a conforming engine. Duplicate detection is inherently a statement *about a group of rows*, so it belongs to the clause that filters groups. ## The group key is the definition of "duplicate" Whatever you list in `GROUP BY` is your definition of sameness, and this is where the real interview follow-up lives. ```sql -- duplicates within a tenant, ignoring letter case SELECT tenant_id, LOWER(email) AS email_key, COUNT(*) AS occurrences FROM users GROUP BY tenant_id, LOWER(email) HAVING COUNT(*) > 1; ``` Grouping by two columns means a pair counts as duplicated only when both parts match. Grouping by an expression such as `LOWER(email)` or `TRIM(email)` makes the comparison case- or whitespace-insensitive, which matters when the duplicates you are hunting were created by inconsistent input rather than by exact repetition. ## Counting distinct actors instead of rows Sometimes the interesting condition is not "more than one row" but "more than one distinct related value": ```sql -- emails claimed by more than one distinct account SELECT email, COUNT(DISTINCT account_id) AS accounts FROM users GROUP BY email HAVING COUNT(DISTINCT account_id) > 1; ``` The pattern is unchanged — group, aggregate, test the aggregate in `HAVING` — only the aggregate expression differs. ## What the query does and does not give you The result contains duplicated **values**, one row per offending group, not the duplicate rows themselves. If you need the actual rows — to inspect or delete them — you have to get back to row level, for example by joining the grouped result back to the base table: ```sql SELECT u.* FROM users u JOIN ( SELECT email FROM users GROUP BY email HAVING COUNT(*) > 1 ) d ON d.email = u.email ORDER BY u.email, u.id; ``` Mistaking one for the other is a common slip: a candidate says "this gives me the duplicate rows" when it gives one summary row per duplicated value. ## Threshold variations The predicate is ordinary boolean logic over aggregates, so it generalises freely: `HAVING COUNT(*) >= 3` for values seen at least three times, `HAVING COUNT(*) = 1` for values seen exactly once (the singletons), and combinations such as `HAVING COUNT(*) > 1 AND MIN(created_at) >= DATE '2026-01-01'` to restrict attention to duplicates that all appeared recently. Note that `HAVING COUNT(*) = 0` is a dead end — a group exists only because rows produced it, so no returned group can have zero rows. ## A NULL caveat Grouping puts all NULL values of the key into a single group, so a table with many NULL emails will report NULL as a heavily "duplicated" value even though SQL's comparison rules would never call two NULLs equal. If NULL is not a duplicate in your data's terms, exclude it at row level with `WHERE email IS NOT NULL` — precisely the kind of per-row condition that belongs in `WHERE`, not in `HAVING`. ## What a strong answer sounds like Write the query without hesitating, name the group key as the definition of duplication, say why `WHERE` cannot express the predicate, and volunteer one refinement — case-insensitive grouping, a composite key, or the join-back needed to see the actual rows.

  • How would you change the query so '[email protected]' and '[email protected]' count as the same duplicate?
    Group by the normalised expression rather than the raw column: `GROUP BY LOWER(email) HAVING COUNT(*) > 1`, selecting `LOWER(email)` too. The group key is the definition of sameness, so normalising it inside GROUP BY is the whole fix. Add TRIM as well if stray whitespace is a possibility in that data.
  • The query returns duplicated values, but you need the actual duplicate rows. What changes?
    Aggregation collapses rows, so you must return to row level. Join the grouped result back to the base table on the duplicated key, or use a subquery of the same shape in an IN or EXISTS predicate. Either way the HAVING query becomes an inner step that identifies which keys to re-expand, not the final result.
  • What does HAVING COUNT(*) = 1 select, and when is it useful?
    It keeps only the groups produced by exactly one row — the singleton values. It is useful as the complement of a duplicate report: unique-so-far keys, customers with a single order, products sold once. The mechanics are identical; only the comparison changes, which shows that HAVING is ordinary boolean logic over aggregate values.

saying these in an interview costs you the question

  • Writes WHERE COUNT(*) > 1 instead of HAVING
  • Says the query returns the duplicate rows themselves
  • Uses SELECT DISTINCT and claims it finds duplicates
  • Forgets the group key defines what duplicate means
  • Expects HAVING COUNT(*) = 0 to find missing values

context