skip to content

In a ROW_NUMBER dedup partitioned by email, what happens to rows whose email is NULL?

level: seniorimportance: should knowfreq 32%

answer

  1. partitioning follows grouping rules, not = comparison
  2. unknown values are not distinct from each other
  3. they all end up in a single group
  4. the delete then hits unrelated rows

basics

~20 s

They all land in one partition. PARTITION BY groups NULLs together as peers rather than using = comparison, so every NULL-email row after the first is numbered above 1 and a delete of rn > 1 wipes out unrelated records.

solid answer

~40 s

Window partitioning does not compare values with `=`; it groups rows the way `GROUP BY` does, treating two NULLs as belonging to the same group. So a hundred rows with a missing email form a single hundred-row partition, get numbered 1 to 100, and a `DELETE ... WHERE rn > 1` removes ninety-nine perfectly distinct customers. That is usually the opposite of the business intent: NULL means "unknown", and two unknown emails are not evidence of the same person. The fix is to exclude the null-keyed rows from the destructive step — `AND email IS NOT NULL` in the outer `DELETE` — or to handle them under a different key entirely. Beware `COALESCE(email, '')`: it makes the grouping explicit but does not change it, since every unknown still collapses into one bucket.

code

sql · 9 lines
sql
-- Dangerous: every row with a missing email forms ONE partition
WITH ranked AS (
    SELECT customer_id, email,
           ROW_NUMBER() OVER (PARTITION BY email
                              ORDER BY created_at DESC, customer_id DESC) AS rn
    FROM customers
)
DELETE FROM customers
WHERE customer_id IN (SELECT customer_id FROM ranked WHERE rn > 1);

go deeper

for a junior

Remember that PARTITION BY puts all NULL keys into one group rather than giving each its own, so a dedup over a nullable column touches rows that are not really duplicates.

for a middle

Explain the two NULL rules: predicates compare with = and never match nulls, while grouping contexts such as PARTITION BY, GROUP BY and DISTINCT treat nulls as not distinct and group them.

for a senior

Demonstrate the operational reflex: census the key for nulls before any destructive dedup, guard the delete with IS NOT NULL, and decide deliberately what a missing key should mean.

for a principal

Own the upstream question: a nullable deduplication key means the identity model is undefined, so the durable fix is a NOT NULL or a proper identifier on the entity, not a smarter cleanup query.

## Two different NULL rules, and which one applies SQL uses NULL inconsistently on purpose. In a **predicate**, `NULL = NULL` evaluates to unknown, so a join or a `WHERE` never matches two nulls. In **grouping** contexts — `GROUP BY`, `DISTINCT`, `PARTITION BY` — nulls are placed together: they are not equal, but they are *not distinct*, and grouping works on not-distinct. Window partitioning follows the grouping rule. That single fact decides what a deduplication query does with missing keys. `PARTITION BY email` builds one partition per distinct email value **plus one partition holding every row where email is NULL**, however many that is and however unrelated those customers are. ## What that does to the numbering ```sql SELECT customer_id, email, ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at DESC, customer_id) AS rn FROM customers; ``` If 400 rows have no email, they form one partition and receive `rn` values 1 through 400. The function is behaving exactly as specified — but the query has silently asserted that all 400 are the same customer. In a read-only query this shows up as a suspiciously small result: you expected "one row per customer" and got one row standing in for hundreds. In a cleanup it is far worse: `DELETE ... WHERE rn > 1` removes 399 real records, and the survivor is whichever one the ordering happened to favour. ## Fixing it The right fix depends on what a missing key means in your data. **Exclude null keys from the destructive step.** Most common and usually correct: an unknown email is not evidence of duplication, so those rows are simply not candidates. ```sql WITH ranked AS ( SELECT customer_id, email, ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at DESC, customer_id DESC) AS rn FROM customers ) DELETE FROM customers WHERE customer_id IN (SELECT customer_id FROM ranked WHERE rn > 1 AND email IS NOT NULL); ``` Filtering `WHERE email IS NOT NULL` inside the CTE works equally well and is cheaper, since those rows never get numbered at all. **Pick a different key for those rows.** If missing emails are common, the real duplicate key may be something else — `(phone, birth_date)` or an external system id. Deduplicate the null-email population separately under that key rather than lumping it in. **Do not reach for COALESCE.** `PARTITION BY COALESCE(email, '')` is sometimes offered as a fix; it changes nothing, because it maps every unknown to the same empty string and re-creates exactly the single mega-partition. It is only useful when the empty string genuinely is a real key value you want merged with NULL. ## The wider habit The same trap appears whenever the deduplication key is composite and any of its columns is nullable: `PARTITION BY email, phone` groups all rows where both are missing, and groups rows sharing an email whose phones are both missing. Before writing the delete, run a quick census of the key: ```sql SELECT COUNT(*) AS total, COUNT(email) AS with_email, COUNT(*) - COUNT(email) AS null_email FROM customers; ``` If the null count is anything but zero, decide explicitly what those rows should do rather than letting the partitioning rule decide for you. The senior instinct here is not memorising the NULL rule — it is refusing to run a destructive statement whose behaviour on missing data you have not checked. ## Interview signals Strong candidates state the grouping rule confidently ("partitioning uses not-distinct, so nulls group together, unlike a join predicate"), predict the concrete blast radius, and name the `IS NOT NULL` guard on the delete. Weak candidates assert that NULL never equals NULL so each null row gets its own partition — a plausible-sounding answer that leads directly to mass deletion in production.

  • Does that grouping rule differ from how a join predicate treats NULL?
    Yes, and that is the whole confusion. A join or WHERE predicate compares with =, and NULL = NULL is unknown, so nulls never match. PARTITION BY, GROUP BY and DISTINCT use not-distinct instead, which places all nulls in one group. Same value, two different rules depending on context.
  • Would PARTITION BY COALESCE(email, '') solve it?
    No. COALESCE maps every unknown email to the same empty string, so the rows still form one partition and the delete still removes them. It only helps when the empty string is a genuine key value that should be merged with NULL, which is rarely the intent.
  • How would you deduplicate the rows that have no email at all?
    Pick a key that actually exists for them — an external system id, or a composite such as phone plus birth date — and run a separate ranked delete over just that population. If no reliable key exists, leave them alone and flag them for manual review, because a delete would be a guess.

saying these in an interview costs you the question

  • Says NULL never equals NULL so each null row gets its own partition
  • Runs the delete without checking how many keys are null
  • Offers COALESCE on the partition key as the fix
  • Assumes rows with a NULL key are skipped by the window function
  • Treats two unknown values as evidence of duplication

context