Approach: create daily signup cohorts, generate day offsets 1..7, join events to check whether each user was active in the Nth day after signup (using time windows), deduplicate activity by user per day, then compute percentage = active_users_on_day_n / cohort_size. Assumptions stated below.SQL (Postgres-compatible):sql
WITH cohorts AS (
SELECT user_id, signup_date::date AS cohort_date
FROM users
),
days AS (
SELECT generate_series(1,7) AS day_offset
),
-- For deduplication: mark whether a user had any event in a given day offset
user_activity AS (
SELECT
c.cohort_date,
c.user_id,
d.day_offset,
-- flag if user had any event in the day window (deduped to 1)
CASE WHEN COUNT(e.event_id) > 0 THEN 1 ELSE 0 END AS active_flag
FROM cohorts c
CROSS JOIN days d
LEFT JOIN events e
ON e.user_id = c.user_id
AND e.occurred_at >= (c.cohort_date + (d.day_offset) * INTERVAL '1 day')
AND e.occurred_at < (c.cohort_date + (d.day_offset + 1) * INTERVAL '1 day')
GROUP BY c.cohort_date, c.user_id, d.day_offset
),
cohort_sizes AS (
SELECT cohort_date, COUNT(*) AS cohort_users
FROM cohorts
GROUP BY cohort_date
),
retention AS (
SELECT
ua.cohort_date,
ua.day_offset,
SUM(ua.active_flag) AS active_users
FROM user_activity ua
GROUP BY ua.cohort_date, ua.day_offset
)
SELECT
r.cohort_date,
cs.cohort_users,
-- retention percentages for day 1..7 as decimals (multiply by 100 if you want %)
ROUND(100.0 * MAX(CASE WHEN r.day_offset = 1 THEN r.active_users::numeric / cs.cohort_users ELSE NULL END),2) AS day_1_pct,
ROUND(100.0 * MAX(CASE WHEN r.day_offset = 2 THEN r.active_users::numeric / cs.cohort_users ELSE NULL END),2) AS day_2_pct,
ROUND(100.0 * MAX(CASE WHEN r.day_offset = 3 THEN r.active_users::numeric / cs.cohort_users ELSE NULL END),2) AS day_3_pct,
ROUND(100.0 * MAX(CASE WHEN r.day_offset = 4 THEN r.active_users::numeric / cs.cohort_users ELSE NULL END),2) AS day_4_pct,
ROUND(100.0 * MAX(CASE WHEN r.day_offset = 5 THEN r.active_users::numeric / cs.cohort_users ELSE NULL END),2) AS day_5_pct,
ROUND(100.0 * MAX(CASE WHEN r.day_offset = 6 THEN r.active_users::numeric / cs.cohort_users ELSE NULL END),2) AS day_6_pct,
ROUND(100.0 * MAX(CASE WHEN r.day_offset = 7 THEN r.active_users::numeric / cs.cohort_users ELSE NULL END),2) AS day_7_pct
FROM retention r
JOIN cohort_sizes cs ON cs.cohort_date = r.cohort_date
GROUP BY r.cohort_date, cs.cohort_users
ORDER BY r.cohort_date;
Key points & assumptions:- Cohort granularity: signup_date::date (daily cohorts).- Day definition: day_offset = N means the time window [cohort_date + N days, cohort_date + N+1 days). Day 1 is the first full day after signup. If you prefer day 0 = signup day, adjust offsets accordingly.- Deduplication: counts unique users per cohort-day (we aggregate events by user and day and use a binary active_flag), preventing multiple events from inflating retention.- Activity window: uses calendar-day windows since signup; for rolling-window in hours use intervals (e.g., 24h windows) instead.- Missing data: cohorts with zero size are excluded; if cohort size is small, percentages can be noisy—consider minimum cohort size filter.- Extensions: include event_name filter (e.g., only "open_app") by adding AND e.event_name = '...'. For longer retention, extend generate_series.