Active Duo
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
- L4
Problem
The growth team is building a cross-engagement segment of users who both make purchases and log browsing sessions on the platform. Return an alphabetical roster of the usernames for users with activity in both areas.
Shoppers who also browse. The overlap is the insight.
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 |
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 |
user_sessions
Columns: session_id INTEGER, user_id INTEGER, device_id INTEGER, session_start TEXT, session_duration_sec INTEGER, pages_viewed INTEGER
| session_id | user_id | device_id | session_start | session_duration_sec | pages_viewed |
|---|---|---|---|---|---|
| 7061 | 197 | 230 | 2026-02-02 01:00:00 | 53 | 3 |
| 7122 | 294 | 259 | 2026-03-03 02:00:00 | 76 | 6 |
| 7183 | 391 | 288 | 2026-04-04 03:00:00 | 99 | 9 |
| 7244 | 488 | 317 | 2026-05-05 04:00:00 | 122 | 12 |
| 7305 | 585 | 346 | 2026-06-06 05:00:00 | 145 | 15 |
| 7366 | 682 | 375 | 2026-07-07 06:00:00 | 168 | 18 |
| 7427 | 779 | 404 | 2026-08-08 07:00:00 | 191 | 21 |
| 7488 | 876 | NULL | 2026-09-09 08:00:00 | 214 | 24 |
| 7549 | 973 | 462 | 2026-01-10 09:00:00 | 237 | 27 |
| 7610 | 1070 | 491 | 2026-02-11 10:00:00 | 260 | 30 |
Expected output
| username |
|---|
| amelia |
| aaron42 |
| aiden |
| alice |
| andrew_k |
Worked solution and explanation
What this really is
This is a set intersection dressed up as a growth segment. A user belongs if they appear in transactions and in user_sessions, and those two tables share nothing but user_id. Anyone can write the three-way join. The trick is seeing what it returns: every purchase paired with every session of the same user, so the rows are purchase-session pairs, not people. On this data that is 1,036 rows for 15 users, between 60 and 96 copies of each name, and a roster with alice on it 96 times is not a roster.
Step 1: Fix the grain before joining
One row per user. Say it first, because it decides everything after: whatever you read from transactions or user_sessions may only confirm a user, never add rows for them.
Step 2: Require both activities on user_id
Inner-join users to transactions and to user_sessions, both on u.user_id. An inner join drops any user missing from either side, which is exactly the both-areas rule. Reading user_sessions alone would admit 59 users; only 15 of them ever bought anything.
Step 3: Collapse the fan-out
Each surviving user now appears once per purchase-session pair, roughly 13 purchases times 5 sessions here. SELECT DISTINCT u.username folds those back to one row each. It is safe because no two of the 15 qualifying users share a username.
Step 4: Sort the roster as stored
ORDER BY u.username gives the A to Z list. Two usernames are stored with stray spaces (' amelia' and 'arjun '), and the roster returns them untouched, which is why ' amelia' sorts ahead of 'aaron42': a space comes before every letter.
The solution
SELECT DISTINCT u.username
FROM users u
JOIN transactions t ON u.user_id = t.user_id
JOIN user_sessions s ON u.user_id = s.user_id
ORDER BY u.usernameSELECT u.username
FROM users u
JOIN transactions t ON u.user_id = t.user_id
JOIN user_sessions s ON u.user_id = s.user_id
ORDER BY u.usernameReturns 1,036 rows: every purchase of a user paired with every one of their sessions.
- Join both, then deduplicate
- Builds every purchase-session pair per user, then throws the duplicates away with
DISTINCT. It is correct and short, and it is the version most interviewers expect first. - Two existence checks
WHERE EXISTS (SELECT 1 FROM transactions t WHERE t.user_id = u.user_id) AND EXISTS (...)asks each user two yes or no questions and stops at the first match in each table. No pairs are built, so nothing needs deduplicating.
Common follow-up questions
- Only purchases with a positive `total_amount` should count. Where does that filter go, and does it change who qualifies here? (Tests filtering inside the purchase check; the seed has one refund and one voided sale, but both users have other purchases.)
- Return each user's purchase count and session count next to the username. (Tests aggregating each activity table before joining, since joining first inflates both counts by the other side's fan-out.)
- Should ' amelia' and 'arjun ' be cleaned before the roster ships, and what would that do to the sort? (Tests noticing stored whitespace and its effect on ordering and matching.)
- Now find the users who browse but never buy. (Tests flipping the intersection into an anti-join with `NOT EXISTS` or a `LEFT JOIN` null check.)