Full Funnel
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
Problem
An analyst sizing the "full lifecycle" segment says her draft list repeats the same usernames hundreds of times. Rebuild it from users, search_queries, page_views and transactions so that each user who has searched, browsed and purchased appears exactly once, alphabetically by username.
Search. Browse. Buy. Only a few do all three.
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 |
search_queries
Columns: query_id INTEGER, user_id INTEGER, search_term TEXT, results_count INTEGER, clicked_result INTEGER, query_time TEXT
| query_id | user_id | search_term | results_count | clicked_result | query_time |
|---|---|---|---|---|---|
| 2071 | 100 | laptop deals | 17 | 2 | 2026-02-02 01:11:00 |
| 2142 | 197 | phone case iphone 15 | 34 | 3 | 2026-03-03 02:22:00 |
| 2213 | 294 | usb-c cable 6ft | 51 | NULL | 2026-04-04 03:33:00 |
| 2284 | NULL | gaming mouse | 68 | 5 | 2026-05-05 04:44:00 |
| 2355 | 488 | mechanical keyboard | 85 | 6 | 2026-06-06 05:55:00 |
| 2426 | 585 | 4k monitor | 102 | NULL | 2026-07-07 06:06:00 |
| 2497 | 682 | standing desk | 119 | 8 | 2026-08-08 07:17:00 |
| 2568 | NULL | webcam 1080p | 136 | 9 | 2026-09-09 08:28:00 |
| 2639 | 876 | bluetooth speaker | 153 | NULL | 2026-01-10 09:39:00 |
| 2710 | 973 | smart watch | 170 | 1 | 2026-02-11 10:50:00 |
page_views
Columns: view_id INTEGER, page_url TEXT, user_id INTEGER, referrer TEXT, dur_ms INTEGER, device TEXT, viewed_at TEXT
| view_id | page_url | user_id | referrer | dur_ms | device | viewed_at |
|---|---|---|---|---|---|---|
| 5053 | /products | 1555 | amazon.com | 637 | desktop | 2026-09-02 01:09:00 |
| 5106 | /checkout | 1652 | twitter.com | 774 | tablet | 2026-09-03 02:18:00 |
| 5159 | /profile | 1749 | github.com | 911 | Mobile | 2026-09-04 03:27:00 |
| 5212 | /search | 1846 | NULL | 1048 | DESKTOP | 2026-09-05 04:36:00 |
| 5265 | /about | 1943 | reddit.com | 1185 | mobile | 2026-09-06 05:45:00 |
| 5318 | /blog/intro | 1555 | NULL | NULL | desktop | 2026-09-07 06:54:00 |
| 5371 | /pricing | 1652 | bing.com | 1459 | tablet | 2026-09-08 07:03:00 |
| 5424 | /docs/api | 1749 | news.ycombinator.com | 1596 | Mobile | 2026-09-09 08:12:00 |
| 5477 | /settings | 1846 | NULL | 1733 | DESKTOP | 2026-09-10 09:21:00 |
| 5530 | /Home | 1943 | bing.com | 1870 | mobile | 2026-09-11 10:30:00 |
transactions
Columns: transaction_id INTEGER, user_id INTEGER, product_id INTEGER, quantity INTEGER, total_amount REAL, transaction_date TEXT
| transaction_id | user_id | product_id | quantity | total_amount | transaction_date |
|---|---|---|---|---|---|
| 1067 | 197 | 1001 | 2 | 23.46 | 2026-02-02 |
| 1134 | 294 | 1050 | 3 | 36.93 | 2026-03-03 |
| 1201 | 391 | 1099 | 4 | 50.4 | 2026-04-04 |
| 1268 | 488 | 1148 | 5 | 63.87 | 2026-05-05 |
| 1335 | 585 | 1197 | 0 | 0 | 2026-06-06 |
| 1402 | 682 | 1246 | 2 | 90.81 | 2026-07-07 |
| 1469 | 779 | 1295 | 3 | 104.28 | 2026-08-08 |
| 1536 | 876 | 1344 | 4 | 117.75 | 2026-09-09 |
| 1603 | 973 | 1393 | 5 | -45 | 2026-01-10 |
| 1670 | 1070 | 1442 | 1 | 144.69 | 2026-02-11 |
Expected output
| username |
|---|
| amelia |
| aaron42 |
| alice |
| andrew_k |
| anika |
Worked solution and explanation
A set intersection in a funnel costume
This is a three-set intersection dressed up as a funnel segment: who is a searcher, a browser and a buyer at once. Everyone sees that much. What separates candidates is the grain. Each activity table holds many rows per user (on this seed a buyer owns 12 to 14 transactions and up to 26 page_views), so chaining inner joins multiplies those rows and every qualifying username comes back hundreds of times. The fix is to test membership instead of matching rows, and to gate on how many different areas a user touched, never on how many rows they produced.
Step 1: Label every activity row with its area
Select just user_id plus a literal area from each source: 'search' from search_queries, 'browse' from page_views, 'purchase' from transactions. Stack them with UNION ALL. Plain UNION would dedupe the stream, but it pays for a sort over every row, and the aggregate below collapses duplicates anyway.
Step 2: Attach the stream to users
Inner-join the stacked stream to users on user_id. That carries the username the roster needs. Activity rows whose user_id is NULL fall away because NULL matches nothing, so anonymous traffic never helps anyone qualify.
Step 3: Collapse to one row per user, gate on areas
GROUP BY u.user_id, u.username turns hundreds of rows per user into one, which is the deduplication the segment needs. HAVING COUNT(DISTINCT a.area) = 3 keeps only users who touched all three areas. Grouping on user_id too keeps apart two people who share a display name.
Step 4: Sort the roster
ORDER BY u.username gives the alphabetical list. The stored ' amelia' with its leading space sorts ahead of every letter: the roster reports usernames exactly as stored.
The solution
SELECT u.username
FROM users u
JOIN (
SELECT user_id, 'search' AS area FROM search_queries
UNION ALL
SELECT user_id, 'browse' AS area FROM page_views
UNION ALL
SELECT user_id, 'purchase' AS area FROM transactions
) a ON a.user_id = u.user_id
GROUP BY u.user_id, u.username
HAVING COUNT(DISTINCT a.area) = 3
ORDER BY u.usernameSELECT u.username
FROM users u
JOIN search_queries s ON s.user_id = u.user_id
JOIN page_views p ON p.user_id = u.user_id
JOIN transactions t ON t.user_id = u.user_id
ORDER BY u.usernameRight users, wrong roster: each username repeats once per search, view and purchase combination.
- Chained inner joins
- Every join multiplies the rows per user. Two searches times twenty views times thirteen purchases is 520 copies of one username. Bolting
DISTINCTon top hides the copies but still builds the whole product first. - Stacked and counted
UNION ALLadds rows instead of multiplying them, so the intermediate is just the sum of the three tables.GROUP BYwithCOUNT(DISTINCT a.area)then asks the membership question directly.
Common follow-up questions
- How would you report how many users reached each stage? (Reuses the stacked stream, counting users per `area` instead of filtering.)
- What if a user only counts when they searched before they browsed, and browsed before they bought? (Needs each user's earliest `query_time`, `viewed_at` and `transaction_date`, compared in order.)
- Where is the biggest drop-off between stages? (Compares per-stage user counts against the previous stage.)
- What changes if `ad_impressions` becomes a fourth stage? (Only a new `UNION ALL` branch and the threshold move; the shape survives.)