Beyond the Signup
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
A month in which every session in user_sessions came from a different person shows no return visits, so the analyst tracking engagement drops it and keeps only months where someone logged more than one session. For each remaining month in the two years leading up to 2026-09-12, report how many users were active and the average session duration.
Anyone can create an account. Fewer actually return.
Tables
users
Columns: user_id INTEGER, signup_date TEXT, account_status TEXT, email TEXT, age_bucket TEXT, username 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, session_start TEXT, session_duration_sec INTEGER, pages_viewed INTEGER, device_id 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
| month | active_users | avg_duration_sec |
|---|---|---|
| 2024-12 | 5 | 1552.4285714285713 |
| 2025-03 | 5 | 1486 |
| 2025-07 | 5 | 1548 |
| 2025-11 | 5 | 1636.5714285714287 |
| 2026-06 | 15 | 1995.1176470588234 |
Worked solution and explanation
What this is really asking
This is a monthly rollup whose filter is built out of the very confusion it tests. Anyone can bucket session_start by month. The trap is the word 'active': one user_id can log several sessions in a month, so **COUNT(*) counts sessions, not people**. The month filter turns that trap inside out: 'someone came back' is a fact about the whole month, never about one session row, so it cannot live in WHERE. Miss either half and you keep months nobody returned in, while reporting session totals as people in the ones you keep.
Note that 'active' never touches users. A user is active in a month when one of their sessions falls in it, so user_sessions is the only table the answer reads.
Build it in three moves
Step 1: Cut the window in WHERE
Keep rows where session_start >= date('2026-09-12', '-24 months'). The cutoff rolls back two years from the anchor date, so the oldest bucket is a partial month. Each session either falls in the window or it does not, which makes it a row filter: it belongs in WHERE, before any grouping.
Step 2: Bucket by month, two aggregates side by side
Group on strftime('%Y-%m', session_start) so every session in a month lands in one bucket. Per bucket, COUNT(DISTINCT user_id) gives the people and AVG(session_duration_sec) gives the session-weighted average; NULL durations simply drop out of it.
Step 3: Keep months with a return visit in HAVING
If every session in a month came from a different person, COUNT(*) equals COUNT(DISTINCT user_id). One person logging twice pushes the session count above the people count. So HAVING COUNT(*) > COUNT(DISTINCT user_id) keeps exactly the months someone came back.
The solution
SELECT strftime('%Y-%m', us.session_start) AS month,
COUNT(DISTINCT us.user_id) AS active_users,
AVG(us.session_duration_sec) AS avg_duration_sec
FROM user_sessions us
WHERE us.session_start >= date('2026-09-12', '-24 months')
GROUP BY month
HAVING COUNT(*) > COUNT(DISTINCT us.user_id)WHERE filters sessions, HAVING filters months; the filter is the gap between the two counts.
- Counts sessions
COUNT(*) AS active_userscounts every session row, so in every month the filter keeps, the figure is too high by exactly the repeat sessions.- Counts people
COUNT(DISTINCT user_id)collapses each user to one per month, and the rawCOUNT(*)stays useful as the other side of theHAVINGcomparison.
SELECT strftime('%Y-%m', session_start) AS month,
COUNT(*) AS active_users,
AVG(session_duration_sec) AS avg_duration_sec
FROM user_sessions
WHERE session_start >= date('2026-09-12', '-24 months')
GROUP BY monthReports session totals as active_users and keeps months nobody returned in.
Common follow-up questions
Common follow-up questions
- How would you keep full calendar months only? (Add `AND session_start < date('2026-09-12', 'start of month')` so the partial current month drops out.)
- What if the bar were at least three returning users, not one? (Count sessions per (user, month) first, then count users with two or more in an outer query.)
- How does the answer change if `users.account_status` matters? (Inner-join `users` on `user_id` and filter to 'active' accounts in `WHERE`, before the unique count.)