The Conversion Story
A medium SQL interview practice problem on DataDriven. Write the query and run it against a real database, with instant feedback.
- Domain
- SQL
- Difficulty
- medium
- Seniority
- L5
- Asked in
- Visa
Problem
Our referral-source conversion numbers don't reconcile, so we're rebuilding them from event_data, labelling each signup or purchase event by its raw tags text as the first of "organic", "referral", "campaign_a" and "campaign_b" that appears there with its double quotes, checked in that order, or 'other' when none does. For every referral_source with at least one signup event, report signup_users, purchase_users and funnel_users (unique users who signed up, purchased, or did either) plus conversion_rate, purchasers over signups rounded to 4 decimal places, with the sources in alphabetical order.
Signups are one thing. Paid purchases are another. Find the gap by source.
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
| referral_source | signup_users | purchase_users | funnel_users | conversion_rate |
|---|---|---|---|---|
| campaign_a | 1 | 0 | 1 | 0 |
| organic | 4 | 1 | 5 | 0.25 |
| other | 1 | 9 | 10 | 9 |
| referral | 1 | 0 | 1 | 0 |
Worked solution and explanation
What this really is
This is a per-source people count wearing a tag-parsing costume. Everyone builds the source label; what separates candidates is counting users, not events, at three grains at once: who signed up, who paid, and who reached either step. Tally rows and a user who signed up twice under one source counts twice in the base. Skip the step filter because the conditional counts look self-filtering, and every user who merely browsed lands in funnel_users, so the columns stop reconciling with each other.
Build it in order
Step 1: Derive the source from tags
There is no referral_source column. Test the raw tags text with LIKE in priority order, organic first and other last, so an event carrying two tokens lands in the higher one. Keep the double quotes inside each pattern so campaign_a only matches the whole token, never a longer tag that happens to contain it. A NULL tags matches nothing and falls through to other.
Step 2: Keep only the two funnel steps
Filter to event_type IN ('signup', 'purchase') before counting anything. The signup and purchase columns would survive without it, but the either-step column would not: drop the filter and a user whose only events are page views and searches inflates funnel_users.
Step 3: Count people at three grains
COUNT(DISTINCT CASE WHEN event_type = 'signup' THEN user_id END) is the signup base, the same shape on 'purchase' is the payers, and a plain COUNT(DISTINCT user_id) over the filtered rows is everyone who reached either step. The CASE hands back NULL for the other step, and the count ignores it.
Step 4: Guard the base, then divide
HAVING drops a source with purchases but no signups, since it has nothing to convert from. The outer query divides the two named counts once, multiplying by 1.0 so integer division cannot floor the rate to zero, and rounds to 4 places.
The solution
WITH sourced_events AS (
SELECT
user_id,
event_type,
CASE
WHEN tags LIKE '%"organic"%' THEN 'organic'
WHEN tags LIKE '%"referral"%' THEN 'referral'
WHEN tags LIKE '%"campaign_a"%' THEN 'campaign_a'
WHEN tags LIKE '%"campaign_b"%' THEN 'campaign_b'
ELSE 'other'
END AS referral_source
FROM event_data
WHERE event_type IN ('signup', 'purchase')
),
source_funnel AS (
SELECT
referral_source,
COUNT(DISTINCT CASE WHEN event_type = 'signup' THEN user_id END) AS signup_users,
COUNT(DISTINCT CASE WHEN event_type = 'purchase' THEN user_id END) AS purchase_users,
COUNT(DISTINCT user_id) AS funnel_users
FROM sourced_events
GROUP BY referral_source
HAVING COUNT(CASE WHEN event_type = 'signup' THEN 1 END) > 0
)
SELECT
referral_source,
signup_users,
purchase_users,
funnel_users,
ROUND(purchase_users * 1.0 / signup_users, 4) AS conversion_rate
FROM source_funnel
ORDER BY referral_source;The counts are named once in source_funnel; the final select only divides and sorts.
Common follow-up questions
- How would you count only users who signed up and then purchased later under the same source? (Pushes from step counts to a true per-user funnel ordered by `event_timestamp`.)
- An event's `tags` carries both `organic` and `campaign_a`. Which source wins, and how would you let marketing change that priority without editing the query? (Probes the priority order and moving it into a lookup table.)
- What would you do instead of dropping a source that has purchases but no signups? (Tests guarding a zero denominator with `NULLIF` versus excluding the row.)