SQL · hard · Reported at Meta
Compute month-over-month user retention from an events table.
Asked in Data Scientist interviews, in the SQL round.
Short answer
Self-join cohort month vs activity month, count distinct users, divide by cohort size.
Sample data
These tables are loaded into an in-browser engine on this page, so you can run your own answer against them. No account needed.
events
| user_id | event_date |
|---|---|
| 1 | 2026-01-05 |
| 2 | 2026-01-09 |
| 3 | 2026-01-15 |
| 4 | 2026-01-20 |
| 1 | 2026-02-03 |
| 2 | 2026-02-11 |
| 5 | 2026-02-14 |
| 1 | 2026-03-02 |
| 5 | 2026-03-08 |
| 6 | 2026-03-12 |
| 2 | 2026-03-19 |
How to answer it
Definition
Of the users first seen in a given month, what share came back in each later month. The output is a cohort grid: cohort month down the side, months since signup across the top.
Approach
- Find each user's cohort month, the month of their first event.
- Join their activity months back to that cohort.
- Count distinct users per cohort and month offset, then divide by the cohort size.
Example
WITH cohort AS (
SELECT user_id, DATE_TRUNC('month', MIN(event_ts)) AS cohort_month
FROM events
GROUP BY user_id
),
activity AS (
SELECT DISTINCT e.user_id,
c.cohort_month,
DATE_TRUNC('month', e.event_ts) AS active_month
FROM events e
JOIN cohort c ON c.user_id = e.user_id
),
sizes AS (
SELECT cohort_month, COUNT(*) AS cohort_size
FROM cohort GROUP BY cohort_month
)
SELECT a.cohort_month,
(EXTRACT(YEAR FROM a.active_month) - EXTRACT(YEAR FROM a.cohort_month)) * 12
+ (EXTRACT(MONTH FROM a.active_month) - EXTRACT(MONTH FROM a.cohort_month)) AS month_number,
COUNT(DISTINCT a.user_id) AS retained,
ROUND(100.0 * COUNT(DISTINCT a.user_id) / MAX(s.cohort_size), 1) AS pct
FROM activity a
JOIN sizes s ON s.cohort_month = a.cohort_month
GROUP BY a.cohort_month, month_number
ORDER BY a.cohort_month, month_number;
Output
cohort_month | month_number | retained | pct
-------------+--------------+----------+-------
2025-01-01 | 0 | 1200 | 100.0
2025-01-01 | 1 | 516 | 43.0
2025-01-01 | 2 | 372 | 31.0
Key points
- Month 0 is always 100% by construction. If it is not, the cohort definition and the activity join disagree.
COUNT(DISTINCT user_id), notCOUNT(*). A user with forty events in a month is one retained user.- The denominator is the cohort size, fixed forever. Dividing by last month's survivors is a different metric.
- The most recent cohorts have fewer observed months. Do not read a young cohort's short curve as a drop.
FAQ
- Is this different from churn? It is the complement, but only when the population is fixed. Churn on a subscription base usually counts cancellations instead.
- Rolling retention or bounded? This is bounded: active in that specific month. Rolling counts active in that month or any later one, and is always higher.
- Why
DATE_TRUNC? It buckets a timestamp to the month start so the join and the grouping agree on what a month is.
The 30-second answer
I would build it as a cohort grid. One CTE giving each user their cohort month, the month of their first event. A second with the distinct months each user was active. Join those, compute the month offset between the two, and count distinct users per cohort and offset, divided by the cohort size.
The details I would flag are that it has to be COUNT DISTINCT, since a user with forty events is still one user, and that the denominator is the original cohort size rather than last month's survivors, or it becomes a different metric. I would also point out that month zero is a hundred percent by construction, so if it is not, the cohort logic is wrong.
In the interview
- What they are checking: whether you define retention before computing it, and whether you count distinct users.
- Common mistake: dividing by the number of rows in the joined result, which is not the cohort size.