Engagement Gap
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 product analytics team wants purchasing activity across the entire user base, including the people who have never bought anything, laid out as an alphabetical roster. Show each user's username next to how many purchases they have made, running from A to Z by username.
Zero transactions is still a data point. Count everyone.
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 |
Expected output
| username | transaction_count |
|---|---|
| amelia | 14 |
| Alice | 0 |
| aaron42 | 14 |
| abel | 0 |
| alice | 12 |
Worked solution and explanation
What this really tests
Strip the business framing and this is a count that has to survive an empty set: for every user, count their transactions, where the users with zero transactions are the whole reason the question exists. On the seed only 15 of the 200 users ever bought anything, so an inner join returns 15 rows and looks correct until you notice 185 people quietly disappeared. The second, quieter trap is what you count: an outer join hands each non-buyer a single all-null transaction row, so COUNT(*) scores that phantom row as 1 and no user ever reads as zero.
Two traps stacked together
Step 1: Keep every user with an outer join
Drive from users and LEFT JOIN transactions on user_id. Buyers match their rows; non-buyers survive with nulls on the transaction side. Swap in an inner join and the exact population the question asked for vanishes without any error.
Step 2: Count the key, not the row
COUNT(t.transaction_id) counts only non-null transaction keys, so a non-buyer's lone all-null row contributes nothing and lands on zero. COUNT(*) counts rows and hands every non-buyer a bogus 1. This one-column choice is the difference between right and confidently wrong.
Step 3: Group by the account, not the name
GROUP BY u.user_id, u.username keeps the account key in the grain. u.username rides along only so it can be selected; two accounts sharing a display name would still come back as two rows.
The solution
SELECT u.username, COUNT(t.transaction_id) AS transaction_count
FROM users u
LEFT JOIN transactions t ON u.user_id = t.user_id
GROUP BY u.user_id, u.username
ORDER BY u.username- Inner join, count rows
- Only the 15 buyers come back; the 185 zero-purchase users you were asked to surface never appear in the result.
- Outer join, count the key
- All 200 users return exactly one row each: buyers get their true count, non-buyers get 0.
Common follow-up questions
- How would you find users who purchased once but never returned? (Tests filtering groups on `COUNT(t.transaction_id) = 1`.)
- Could you list only the non-buyers without counting anything? (Tests an anti-join: users where `NOT EXISTS` a transaction.)
- What if you needed the non-buyers as a percentage of all users? (Tests a scalar subquery for the denominator.)