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

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

user_sessions

Columns: session_id INTEGER, user_id INTEGER, device_id INTEGER, session_start TEXT, session_duration_sec INTEGER, pages_viewed INTEGER

user_sessions: 10 of 200 rows
session_iduser_iddevice_idsession_startsession_duration_secpages_viewed
70611972302026-02-02 01:00:00533
71222942592026-03-03 02:00:00766
71833912882026-04-04 03:00:00999
72444883172026-05-05 04:00:0012212
73055853462026-06-06 05:00:0014515
73666823752026-07-07 06:00:0016818
74277794042026-08-08 07:00:0019121
7488876NULL2026-09-09 08:00:0021424
75499734622026-01-10 09:00:0023727
761010704912026-02-11 10:00:0026030

transactions

Columns: transaction_id INTEGER, user_id INTEGER, total_amount REAL, product_id INTEGER, quantity INTEGER, 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 200 rows
usernamesession_counttotal_amount
alice88605.98
aaron4258814.54
amelia59003.12
arjun 59191.7
ava9959380.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

Pre-aggregate, then LEFT JOIN to avoid fan-out
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_id

Common follow-up questions