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

users: 10 of 200 rows
user_idusernameemailsignup_dateaccount_statusage_bucket
100alicealice@example.com2025-02-02inactive25-34
197aaron42aaron42@example.com2026-03-03suspended35-44
294 ameliaamelia@example.com2024-04-04pending_verification45-54
391arjun arjun@example.com2025-05-05active55-64
488ava99ava99@example.com2026-06-06inactive65+
585andrew_kandrew_k@example.com2024-07-07suspendedNULL
682anikaanika@example.com2025-08-08 16:34:00pending_verification18-24
779aidenaiden@example.com2026-09-09 14:53:00active25-34
876aria_baria_b@example.com2024-10-10inactive35-44
973brianbrian@example.com2025-11-11suspended45-54

search_queries

Columns: query_id INTEGER, user_id INTEGER, search_term TEXT, results_count INTEGER, clicked_result INTEGER, query_time TEXT

search_queries: 10 of 200 rows
query_iduser_idsearch_termresults_countclicked_resultquery_time
2071100laptop deals1722026-02-02 01:11:00
2142197phone case iphone 153432026-03-03 02:22:00
2213294usb-c cable 6ft51NULL2026-04-04 03:33:00
2284NULLgaming mouse6852026-05-05 04:44:00
2355488mechanical keyboard8562026-06-06 05:55:00
24265854k monitor102NULL2026-07-07 06:06:00
2497682standing desk11982026-08-08 07:17:00
2568NULLwebcam 1080p13692026-09-09 08:28:00
2639876bluetooth speaker153NULL2026-01-10 09:39:00
2710973smart watch17012026-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

page_views: 10 of 200 rows
view_idpage_urluser_idreferrerdur_msdeviceviewed_at
5053/products1555amazon.com637desktop2026-09-02 01:09:00
5106/checkout1652twitter.com774tablet2026-09-03 02:18:00
5159/profile1749github.com911Mobile2026-09-04 03:27:00
5212/search1846NULL1048DESKTOP2026-09-05 04:36:00
5265/about1943reddit.com1185mobile2026-09-06 05:45:00
5318/blog/intro1555NULLNULLdesktop2026-09-07 06:54:00
5371/pricing1652bing.com1459tablet2026-09-08 07:03:00
5424/docs/api1749news.ycombinator.com1596Mobile2026-09-09 08:12:00
5477/settings1846NULL1733DESKTOP2026-09-10 09:21:00
5530/Home1943bing.com1870mobile2026-09-11 10:30:00

transactions

Columns: transaction_id INTEGER, user_id INTEGER, product_id INTEGER, quantity INTEGER, total_amount REAL, transaction_date TEXT

transactions: 10 of 200 rows
transaction_iduser_idproduct_idquantitytotal_amounttransaction_date
10671971001223.462026-02-02
11342941050336.932026-03-03
12013911099450.42026-04-04
12684881148563.872026-05-05
13355851197002026-06-06
14026821246290.812026-07-07
146977912953104.282026-08-08
153687613444117.752026-09-09
160397313935-452026-01-10
1670107014421144.692026-02-11

Expected output

First 5 of 12 rows
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

Stack, tag, count areas
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.username
The chained-join version
SELECT 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.username

Right 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 DISTINCT on top hides the copies but still builds the whole product first.
Stacked and counted
UNION ALL adds rows instead of multiplying them, so the intermediate is just the sum of the three tables. GROUP BY with COUNT(DISTINCT a.area) then asks the membership question directly.

Common follow-up questions