The Fault Lines
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
- DoorDash
Problem
To see which account groups have the roughest time in the product, find each account status's share of events that ended badly, given as a decimal fraction of all of that group's events. Treat an event as bad when its type is error, timeout, or crash.
Some account groups hit far more errors than others. Surface the ones that break most.
Tables
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 |
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
| account_status | negative_rate |
|---|---|
| active | 0.13333333333333333 |
| inactive | 0.14893617021276595 |
| pending_verification | 0.14285714285714285 |
| suspended | 0.17073170731707318 |
Worked solution and explanation
What this really is
This is a per-group conditional rate wearing a product-analytics costume. The real question: can you compute count(bad) over count(all) inside each account_status bucket without letting integer division flatten every rate to zero? Anyone can write the CASE expression. The two things that separate candidates are casting the numerator to REAL before the divide, and knowing this rate is event-weighted, so a single noisy account can swing a whole bucket. Get the cast wrong and every dashboard cell reads 0.
The traps
Step 1: Event-weighted, not user-weighted
The rate is bad_events / all_events within each status, so one power user with 1000 events dominates that bucket. Picture two suspended accounts: A fires 999 clean events and 1 error, B fires a single error. The event-weighted rate is 2 out of 1001, roughly 0.002, but the user-weighted rate is 0.5, since half the accounts hit a bad event. The prompt asks for the share of events, so event-weighted is right here. A senior candidate still names the fork aloud, because product teams often mean the user-weighted version.
Step 2: Integer division destroys the answer
CAST(SUM(...) AS REAL) / COUNT(*) forces floating-point division. Without the cast, SQLite (and Postgres with two integer operands, and MySQL in strict mode) does integer division. SUM = 1, COUNT = 3, result = 0. Every rate below 1.0 collapses to zero and every rate at or above 1.0 collapses to 1. The query runs cleanly, the column type looks plausible, and the metric is silently wrong until someone checks a bucket by hand.
Step 3: The join sets the grain and quietly drops rows
Grouping by u.account_status means every counted event must belong to a user whose status is known, and that happens through the INNER JOIN. Two silent drops: events with a NULL user_id (system events) never match a users row, and users who fired zero events never reach the SELECT at all. Both are usually fine for an event-weighted rate, but say it aloud: the denominator is events that joined, not all events and not all accounts.
The solution
SELECT
u.account_status,
CAST(SUM(CASE WHEN ed.event_type IN ('error', 'timeout', 'crash') THEN 1 ELSE 0 END) AS REAL)
/ COUNT(*) AS negative_rate
FROM users u
JOIN event_data ed ON u.user_id = ed.user_id
GROUP BY u.account_statusCommon follow-up questions
- Rewrite this as a user-weighted rate where each account contributes one observation regardless of event count. How does the answer change for the two-account toy example in trap 1? (Tests whether the candidate can turn an event-weighted aggregate into a user-weighted one, via a per-user subquery that flags whether each user had any bad event, then averages that flag within each status.)
- Why does the query cast the SUM to REAL before dividing by COUNT(*) instead of dividing the two integers directly? (Probes whether the candidate knows the integer-division behavior of SQLite, Postgres, and MySQL, and can name at least one alternative (multiply by 1.0, AVG with float literals, NUMERIC cast).)
- If 5 percent of events have a NULL `event_type`, do they appear in the denominator of a bucket? Do they appear in the numerator? Is that the behavior you want? (Tests three-valued logic intuition. NULL IN (...) evaluates to NULL, so the CASE returns 0 and the event is excluded from the numerator, while COUNT(*) still counts the row, so it stays in the denominator.)
- An account had zero events in the whole window. Does it show up in your result with a rate of 0, or does it vanish? What would you change if the business wanted it listed? (Probes whether the candidate recognizes that NULL `user_id` events and event-less users are both dropped by the INNER JOIN, and can reason about when a LEFT JOIN or a different grain would be needed.)
- If we restricted this to the last 30 days of events, how would you write the timestamp predicate so the planner can prune the `event_timestamp` partitions instead of scanning the whole table? (Tests SARGability and scaling awareness for the version of this query that does add a time bound: a function-free comparison on `event_timestamp` lets the planner prune the 365 daily partitions instead of scanning all 200M rows.)