Gone to Ground
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
- L3
- Asked in
Problem
Our mobile A/B tests keep a control group that runs on Android, and we're checking which of those users have gone quiet. For each one whose account is no longer active, return their age bracket alongside the platform and variant, oldest experiment assignment first.
The control group held steady. Some of them slipped away. Find the ones who stopped answering.
Tables
experiments
Columns: exp_id INTEGER, exp_name TEXT, variant TEXT, user_id INTEGER, outcome REAL, created TEXT, platform TEXT
| exp_id | exp_name | variant | user_id | outcome | created | platform |
|---|---|---|---|---|---|---|
| 1053 | checkout_v3 | variant_a | 100 | 11.3 | 2026-02-02 | android |
| 1106 | onboard_flow | variant_b | 197 | 22.6 | 2026-03-03 | web |
| 1159 | pricing_tier | variant_c | 294 | 33.9 | 2026-04-04 | iOS |
| 1212 | search_rank | holdout | 391 | 45.2 | 2026-05-05 | Android |
| 1265 | rec_model_b | control | 488 | 56.5 | 2026-06-06 | NULL |
| 1318 | notif_freq | variant_a | 585 | NULL | 2026-07-07 | WEB |
| 1371 | home_layout | variant_b | 682 | 79.1 | 2026-08-08 | NULL |
| 1424 | new_feed_algo | variant_c | 779 | 90.4 | 2026-09-09 | ios |
| 1477 | checkout_v3 | holdout | 876 | 1.7 | 2026-01-10 | android |
| 1530 | onboard_flow | control | 973 | 13 | 2026-02-11 | web |
users
Columns: user_id INTEGER, username TEXT, email TEXT, signup_date TEXT, account_status TEXT, age_bucket TEXT
| user_id | username | signup_date | account_status | age_bucket | |
|---|---|---|---|---|---|
| 100 | alice | alice@example.com | 2025-02-02 | inactive | 25-34 |
| 197 | aaron42 | aaron42@example.com | 2026-03-03 | suspended | 35-44 |
| 294 | amelia | amelia@example.com | 2024-04-04 | pending_verification | 45-54 |
| 391 | arjun | arjun@example.com | 2025-05-05 | active | 55-64 |
| 488 | ava99 | ava99@example.com | 2026-06-06 | inactive | 65+ |
| 585 | andrew_k | andrew_k@example.com | 2024-07-07 | suspended | NULL |
| 682 | anika | anika@example.com | 2025-08-08 16:34:00 | pending_verification | 18-24 |
| 779 | aiden | aiden@example.com | 2026-09-09 14:53:00 | active | 25-34 |
| 876 | aria_b | aria_b@example.com | 2024-10-10 | inactive | 35-44 |
| 973 | brian | brian@example.com | 2025-11-11 | suspended | 45-54 |
Expected output
| user_id | age_bucket | platform | variant |
|---|---|---|---|
| 6308 | 35-44 | android | control |
| 6308 | 35-44 | android | control |
| 2428 | 55-64 | android | control |
| 2428 | 55-64 | android | control |
Worked solution and explanation
What this is really asking
Beneath the experiment framing this is a two-table filter where the cohort lives in one table and the qualifying signal lives in the other. Two things earn a stare. First, the platform tag arrives in mixed casing, so the Android arm is both 'android' and 'Android'; normalize the case before you match or you silently halve the cohort. Second, account_status <> 'active' has real work to do: the Android-cased control users are active accounts, and this is the predicate that drops them, while under three-valued logic the same predicate silently drops any NULL status, so where you put it decides whether the join means what you think. The output also has to carry age_bucket, which lives in users, so a semi-join subquery will not do here. You actually have to join the two tables and project a users column. The rest is a vanilla inner join with two filters and ORDER BY experiments.created.
Break down the requirements
Step 1: Filter experiments first
variant='control' AND LOWER(platform)='android' on the 1.5M-row table. Normalizing the casing so both 'android' and 'Android' count, then pushing both predicates down before the join, shrinks the probe. Note that LOWER(platform) also excludes the NULL-platform rows, which is what you want.
Step 2: Inner-join to users
Join on user_id, filter u.account_status <> 'active' (this is what removes the active Android-cased control users), and project u.age_bucket into the output. INNER is correct: a missing user row has no age bracket and cannot satisfy the status predicate, so there is nothing to preserve.
The solution
SELECT e.user_id, u.age_bucket, e.platform, e.variant
FROM experiments e
INNER JOIN users u ON e.user_id = u.user_id
WHERE e.variant = 'control'
AND LOWER(e.platform) = 'android'
AND u.account_status <> 'active'
ORDER BY e.created ASC;COMMON FOLLOW-UP QUESTIONS
Common follow-up questions
- What if a user has multiple experiments rows for the same `exp_id`? (One per assignment or one per user: add DISTINCT or aggregate by `user_id`.)
- How would you compare inactive rates across control and treatment on android? (GROUP BY variant, count `user_ids` with status other than 'active' over totals, then take the ratio.)
- What index covers this for an hourly run? (Composite on experiments(variant, platform, created); `users.user_id` is already the PK.)