The Slow Fade
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
- Asked in
- iHeartMedia
Problem
We review signup retention one calendar year at a time, so each cohort's curve ends in December of the year its users registered. Put each user in users into a cohort by the calendar month they registered, then for each month from signup through that December report the decimal fraction of the whole cohort with at least one session in user_sessions that month, labeled by how many months after signup it falls and leaving out any month in which nobody from the cohort came back. Walk from the oldest cohort to the newest, each curve running from the signup month forward.
They all arrive together. Watch the room empty, month by month.
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 |
Expected output
| cohort_month | months_since_signup | retention_rate |
|---|---|---|
| 2024-01 | 7 | 0.25 |
| 2024-07 | 1 | 0.375 |
| 2025-02 | 5 | 0.3333333333333333 |
| 2026-03 | 3 | 0.2857142857142857 |
| 2026-06 | 0 | 0.07142857142857142 |
Worked solution and explanation
What this really is
Strip off the growth-metric costume and this is a per-person membership count inside a fenced window. Each cohort's curve lives only inside the calendar year its users signed up in, and the rate is unique active users divided by the whole cohort. Anyone can bucket by month. What separates candidates is the fence and the count. Subtract month digits without pinning each session to the cohort's own year, and the seed's '2025-02' cohort quietly absorbs its 2026 sessions as months 0 through 7. Count session rows instead of people, and '2024-07' reads 0.625 at month 1 when only three of its eight users came back.
Why the year has to match first
Inside one calendar year, months since signup is just the month digits subtracted: substr(s.session_start, 6, 2) - substr(c.cohort_month, 6, 2). That shortcut is only honest because the year is pinned before the arithmetic runs. Compare substr(s.session_start, 1, 4) to substr(c.cohort_month, 1, 4) as text, and every session from another year drops out. Leave the comparison off, and a session two Decembers later lands at a perfectly plausible offset that nobody will question on a dashboard.
Break down the requirements
Step 1: Bucket signups by year-month
substr(signup_date, 1, 7) gives each user a cohort_month of 'YYYY-MM'. Taking the prefix rather than the raw value matters on this seed: a couple of signup_date values carry a time of day, and the prefix files them with everyone else from that month.
Step 2: Pin the denominator to the whole cohort
cohort_sizes is its own CTE because the base is everyone who signed up that month, COUNT(*) over cohorts, never the users active in some later month. Compute it once, and every (cohort_month, months_since_signup) cell divides by the same number.
Step 3: Fence each session into the signup year
Join cohorts to user_sessions on user_id and keep a session only when its year prefix equals the cohort's. Then the month digits alone give months_since_signup, and the outer WHERE st.months_since_signup >= 0 drops activity from earlier in that year than the signup.
Step 4: Count people, then divide as a decimal
Aggregate with COUNT(DISTINCT st.user_id) so several sessions in one month collapse to one retained user. Then CAST that count to a float before dividing by cs.cohort_size, or SQLite hands you integer division and a wall of zeros.
The solution
WITH cohorts AS (
SELECT user_id, substr(signup_date, 1, 7) AS cohort_month
FROM users
),
cohort_sizes AS (
SELECT cohort_month, COUNT(*) AS cohort_size
FROM cohorts
GROUP BY cohort_month
),
sessions_tagged AS (
SELECT
c.cohort_month,
s.user_id,
substr(s.session_start, 6, 2) - substr(c.cohort_month, 6, 2) AS months_since_signup
FROM cohorts c
JOIN user_sessions s ON c.user_id = s.user_id
WHERE substr(s.session_start, 1, 4) = substr(c.cohort_month, 1, 4)
)
SELECT
st.cohort_month,
st.months_since_signup,
CAST(COUNT(DISTINCT st.user_id) AS DOUBLE) / cs.cohort_size AS retention_rate
FROM sessions_tagged st
JOIN cohort_sizes cs ON st.cohort_month = cs.cohort_month
WHERE st.months_since_signup >= 0
GROUP BY st.cohort_month, st.months_since_signup, cs.cohort_size
ORDER BY st.cohort_month, st.months_since_signupCommon follow-up questions
- How would you let each curve run for its first 12 months, even across a New Year? (Tests replacing the same-year fence with a year-aware offset, `(session_year - cohort_year) * 12` plus the month difference, capped at 11.)
- How would you show month 0 as an explicit row for every cohort, even when nobody in it had a signup-month session? (Tests building a cohort-by-offset spine and left-joining the active counts so empty cells read 0.)
- A user registers, goes quiet, then registers again months later. How does that distort the cohort? (Tests cohort-assignment thinking: the query pins each user to one `signup_date`, so reactivation needs its own cohort definition.)