COUNT(DISTINCT col) skips NULLs completely. SELECT DISTINCT col treats NULL as a normal value and returns it as one row. So the two can disagree by one.
Five rows, both ways
Say events.user_id holds 1, 1, 2, NULL, NULL.
SELECT COUNT(*) FROM events; -- 5 SELECT COUNT(user_id) FROM events; -- 3 (NULLs not counted) SELECT COUNT(DISTINCT user_id) FROM events; -- 2 (1 and 2) SELECT DISTINCT user_id FROM events; -- 3 rows: 1, 2, NULL
Every aggregate except COUNT(*) ignores NULL inputs. COUNT(DISTINCT ...) is no different. DISTINCT on the other hand is about removing duplicate rows, and for that purpose two NULLs count as the same value.
When you want NULL counted
Sometimes "anonymous users" should be one more bucket in the count. Two ways to get there:
-- Option 1: replace NULL with a sentinel that cannot clash with real data
SELECT COUNT(DISTINCT COALESCE(CAST(user_id AS VARCHAR), '__none__')) FROM events; -- 3
-- Option 2: add 1 if any NULL exists
SELECT COUNT(DISTINCT user_id)
+ MAX(CASE WHEN user_id IS NULL THEN 1 ELSE 0 END) AS n
FROM events; -- 3The sentinel in option 1 must be something no real id can be. A sentinel of 0 or -1 is a common bug, because one day a real row has that value and gets merged with the NULLs.
What interviewers look for
This is a check that you know NULL handling in aggregates. A common follow-up is "what does AVG do with NULLs?" It skips them too, so the average divides by the number of non-NULL rows, not by the total row count. If the NULLs really mean zero, say so explicitly with COALESCE(x, 0).