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_data: 10 of 200 rows
event_idevent_typeuser_idevent_timestamppayloadtagsproperties
3059button_click1002026-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"}
3118purchase1972026-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"}
3177signup2942026-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"}
3236login3912026-05-05 04:28:00{"login_method": "password", "remember_me": true, "mfa_used": false}["web", "returning"]{"device_type": "JP", "referrer": "ja", "utm_medium": "1440x900"}
3295logout4882026-06-06 05:35:00{"action": "logout", "value": 50}["premium", "new_user", "organic"]{"screen_size": "de", "utm_source": "tablet", "session_id": "amazon", "os": "Chrome"}
3354search5852026-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"}
3413add_to_cartNULL2026-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"}
3472open7792026-09-09 08:56:00{"action": "open", "value": 80}["new_user", "organic"]{"utm_medium": "bing", "browser": "organic", "country": "iOS"}
3531crash8762026-01-10 09:03:00{"action": "crash", "value": 90}["campaign_a", "referral", "experiment_v1"]{"session_id": "direct", "os": "Firefox", "language": "macOS", "screen_size": "JP"}
3590error9732026-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

First 5 of 53 rows
monthnew_user_ratioreturning_user_ratio
2020-0110
2022-0110
2022-0210
2022-0310
2022-0410

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

MATERIALIZED-ONCE ANCHOR JOIN
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-probes event_data per 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