Even the Silent Ones
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
- Citizens
Problem
We're building a single per-user report that pairs how often each account shows up with how much it has spent. Every user belongs on it, including the ones that never started a session or never made a purchase.
The quietest accounts still belong in the numbers.
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 |
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 |
transactions
Columns: transaction_id INTEGER, user_id INTEGER, total_amount REAL, product_id INTEGER, quantity INTEGER, 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 | session_count | total_amount |
|---|---|---|
| alice | 8 | 8605.98 |
| aaron42 | 5 | 8814.54 |
| amelia | 5 | 9003.12 |
| arjun | 5 | 9191.7 |
| ava99 | 5 | 9380.28 |
Worked solution and explanation
What this problem really is
Strip the business costume and this is a per-user set of independent rollups bolted onto a full roster: count each account's sessions, sum each account's spend, and keep every account whether it has any activity or not. Anyone can write the joins. The trap is that sessions and transactions are two separate one-to-many branches hanging off the same user_id, and if you join them together before you aggregate, they multiply each other. A user with 10 sessions and 20 transactions becomes 200 rows: the session count reads 200 and the revenue is inflated tenfold. Miss the outer join and every silent account disappears from the report entirely.
Build it up
Step 1: Collapse sessions to one row per user
In a CTE, SELECT user_id, COUNT(*) AS session_count FROM user_sessions GROUP BY user_id reduces the session rows to one row per user. That single row is what makes the later join safe: there is nothing left to fan out against.
Step 2: Collapse transactions to one row per user
A second CTE, SELECT user_id, SUM(total_amount) AS total_amount FROM transactions GROUP BY user_id, does the same for spend. It is a SUM, not a COUNT: the ask is how much each account spent, and counting transaction rows is a common slip.
Step 3: Anchor on the user roster with LEFT JOIN
FROM users u LEFT JOIN session_agg sa ON u.user_id = sa.user_id LEFT JOIN txn_agg ta ON u.user_id = ta.user_id anchors on the full user roster, so an account with zero sessions or zero transactions still comes through. Swap either LEFT JOIN for an inner join and those accounts vanish: on this data that is most of the roster.
Step 4: Turn the misses into zeros
A user who matched neither CTE arrives with NULLs. COALESCE(session_count, 0) and COALESCE(total_amount, 0) turn those into the zeros the report is supposed to read as no activity.
The solution
WITH session_agg AS (
SELECT user_id, COUNT(*) AS session_count
FROM user_sessions
GROUP BY user_id
),
txn_agg AS (
SELECT user_id, SUM(total_amount) AS total_amount
FROM transactions
GROUP BY user_id
)
SELECT
u.username,
COALESCE(sa.session_count, 0) AS session_count,
COALESCE(ta.total_amount, 0) AS total_amount
FROM users u
LEFT JOIN session_agg sa ON u.user_id = sa.user_id
LEFT JOIN txn_agg ta ON u.user_id = ta.user_idCommon follow-up questions
- Now they only want accounts that both logged a session and made a purchase. What changes? (Tests whether the candidate understands inner vs left join semantics and can switch the anchor deliberately.)
- How would you add average transaction value per user without scanning `transactions` a second time? (Tests whether the candidate can add a second metric inside an existing CTE rather than re-scanning the table.)
- Both child tables are huge. Why pre-aggregate in CTEs instead of joining everything and grouping at the end? (Tests the core fan-out reasoning: why aggregate before joining rather than join then group.)
- This report needs to refresh hourly over 60M+ transactions. How would you keep it from re-scanning everything each run? (Tests awareness of incremental refresh and materialization at production scale.)