New vs Returning User Share
A hard SQL interview practice problem on DataDriven. Write the query and run it against a real database, with instant feedback.
- Domain
- SQL
- Difficulty
- hard
- Seniority
- L4
- Asked in
- Yammer
Problem
We count a user as new in the month of their very first event in event_data and as returning in every month they are active after that. Show what share of each month's active users were new and what share were returning, earliest month first.
Fresh faces versus familiar ones.
Tables
event_data
Columns: event_id INTEGER, event_type TEXT, user_id INTEGER, event_timestamp TEXT, payload TEXT, tags TEXT, properties TEXT
| event_id | event_type | user_id | event_timestamp | payload | tags | properties |
|---|---|---|---|---|---|---|
| 3059 | button_click | 100 | 2026-02-02 01:07:00 | {"button_id": "cancel", "element_class": "btn-primary", "position": {"x": 13, "y": 7}} | ["desktop", "web", "returning"] | {"os": "Edge", "language": "US", "screen_size": "en", "utm_source": "mobile"} |
| 3118 | purchase | 197 | 2026-03-03 02:14:00 | {"amount": 56.93, "currency": "GBP", "items": [{"sku": "SKU1002", "qty": 3}]} | ["ios", "premium", "new_user", "organic"] | {"country": "Windows", "device_type": "FR", "referrer": "fr", "utm_medium": "1920x1080", "browser": "twitter"} |
| 3177 | signup | 294 | 2026-04-04 03:21:00 | {"signup_method": "apple", "referral_code": "REF3X", "accepted_terms": true} | ["android", "free", "campaign_a", "referral", "experiment_v1"] | {"language": "iOS", "screen_size": "es", "utm_source": "desktop", "session_id": "bing", "os": "organic"} |
| 3236 | login | 391 | 2026-05-05 04:28:00 | {"login_method": "password", "remember_me": true, "mfa_used": false} | ["web", "returning"] | {"device_type": "JP", "referrer": "ja", "utm_medium": "1440x900"} |
| 3295 | logout | 488 | 2026-06-06 05:35:00 | {"action": "logout", "value": 50} | ["premium", "new_user", "organic"] | {"screen_size": "de", "utm_source": "tablet", "session_id": "amazon", "os": "Chrome"} |
| 3354 | search | 585 | 2026-07-07 06:42:00 | {"query": "phone", "results_count": 102, "filters": {"category": "electronics"}} | ["free", "campaign_a", "referral", "experiment_v1"] | {"referrer": "mobile", "utm_medium": "375x812", "browser": "email", "country": "Edge", "device_type": "US"} |
| 3413 | add_to_cart | NULL | 2026-08-08 07:49:00 | {"product_id": 1007, "quantity": 2, "price": 58.49, "variant": "medium"} | ["returning", "campaign_b", "featured", "experiment_v2", "ios"] | {"utm_source": "fr", "session_id": "twitter", "os": "Safari", "language": "Windows", "screen_size": "FR"} |
| 3472 | open | 779 | 2026-09-09 08:56:00 | {"action": "open", "value": 80} | ["new_user", "organic"] | {"utm_medium": "bing", "browser": "organic", "country": "iOS"} |
| 3531 | crash | 876 | 2026-01-10 09:03:00 | {"action": "crash", "value": 90} | ["campaign_a", "referral", "experiment_v1"] | {"session_id": "direct", "os": "Firefox", "language": "macOS", "screen_size": "JP"} |
| 3590 | error | 973 | 2026-02-11 10:10:00 | {"action": "error", "value": 100} | ["campaign_b", "featured", "experiment_v2", "ios"] | {"browser": "Chrome", "country": "Android", "device_type": "DE", "referrer": "de", "utm_medium": "tablet"} |
Expected output
| month | new_user_ratio | returning_user_ratio |
|---|---|---|
| 2020-01 | 1 | 0 |
| 2022-01 | 1 | 0 |
| 2022-02 | 1 | 0 |
| 2022-03 | 1 | 0 |
| 2022-04 | 1 | 0 |
Worked solution and explanation
Why this problem exists in real interviews
Cohort dashboards live on the new-vs-returning split, and the interviewer is checking whether you reach for a materialized anchor CTE instead of a correlated subquery that re-scans 300M rows per user. They also want to hear COUNT(DISTINCT user_id), not COUNT(*), when counting active users, because a user with 30 events in a month still counts once.
Break down the requirements
Step 1: Anchor each user to their first month
SELECT user_id, strftime('%Y-%m', MIN(event_timestamp)) FROM event_data GROUP BY user_id. One row per user. This is the global truth used by every downstream month.
Step 2: Build the per-month active set
SELECT DISTINCT user_id, strftime('%Y-%m', event_timestamp) AS month FROM event_data. DISTINCT collapses a user firing 400 events in March into a single (user, month) row. Without it, the ratios become event-share, not user-share.
Step 3: Join active to anchor
Inner join on user_id. Every active user has exactly one anchor row, so the join is one-to-one per (user, month). No fan-out.
Step 4: Conditional aggregate the labels
SUM(CASE WHEN ufm.first_month = ma.month THEN 1 ELSE 0 END) for new, < ma.month for returning. Divide by COUNT(*) over the joined set, which equals distinct active users for that month. CAST(... AS REAL) to force float division.
The solution
WITH user_first_month AS (
SELECT
user_id,
strftime('%Y-%m', MIN(event_timestamp)) AS first_month
FROM event_data
GROUP BY user_id
),
monthly_active AS (
SELECT DISTINCT
user_id,
strftime('%Y-%m', event_timestamp) AS month
FROM event_data
)
SELECT
ma.month,
CAST(SUM(CASE WHEN ufm.first_month = ma.month THEN 1 ELSE 0 END) AS REAL) / COUNT(*) AS new_user_ratio,
CAST(SUM(CASE WHEN ufm.first_month < ma.month THEN 1 ELSE 0 END) AS REAL) / COUNT(*) AS returning_user_ratio
FROM monthly_active ma
JOIN user_first_month ufm ON ma.user_id = ufm.user_id
GROUP BY ma.month
ORDER BY ma.month- Correlated, O(N x U)
WHERE NOT EXISTS (SELECT 1 FROM event_data e2 WHERE e2.user_id = e.user_id AND e2.event_timestamp < date_trunc('month', e.event_timestamp)). Correct, but the planner re-probesevent_dataper outer row. On 300M rows this is hours, not minutes.- Materialized CTE, O(N)
- Compute
MIN(event_timestamp)per user once, store U rows, hash-join. One pass to build, one pass to probe. Scales linearly with events, not events times users.
COMMON FOLLOW-UP QUESTIONS
Common follow-up questions
- How would the query change if you wanted 'new' defined by `users.signup_date` instead of first event? (Tests whether the candidate sees that anchor source is a product decision, not a SQL detail, and that the JOIN target swaps from a derived CTE to a dimension table.)
- How would you incrementalize this so each new month doesn't re-scan all 300M rows? (Probes whether they'd persist `user_first_month` as a slowly changing table (insert-only, anchor never moves backward) and only re-aggregate the latest month.)
- What breaks if a user has events in March, then no events for a year, then events in next March? (Tests the difference between 'new' (first ever) and 'resurrected' (returning after a gap), a common product-analytics nuance the prompt collapses.)
- How would you add a third bucket for 'resurrected' users with a 90-day inactivity threshold? (Forces a LAG over per-user months and a date diff, exercising window functions on top of the same anchor-join shape.)
- The `new_user_ratio` in the first month of the dataset is always 1.0. Is that a bug? (Catches whether the candidate recognizes the left-censoring boundary: anyone whose true first event predates the dataset gets falsely tagged new in month one.)