Approach: assign each user to a weekly cohort by their first-ever event (DATE_TRUNC to week), then for each cohort count users and compute how many of those users were active on day 0..7 relative to their first event. Day N = had any event with event_time >= first_event + N days and < first_event + N+1 days. Use DISTINCT user counts so duplicates don’t inflate retention.SQL (Postgres / BigQuery style):sql
WITH first_event AS (
-- determine each user's first event timestamp and cohort week
SELECT
user_id,
MIN(event_time) AS first_event_time,
DATE_TRUNC('week', MIN(event_time)) AS cohort_week
FROM events
GROUP BY user_id
),
events_rel AS (
-- join original events to first_event to compute day offset
SELECT
f.user_id,
f.cohort_week,
f.first_event_time,
e.event_time,
FLOOR(EXTRACT(EPOCH FROM (e.event_time - f.first_event_time)) / 86400) AS day_offset
FROM first_event f
JOIN events e
ON e.user_id = f.user_id
-- keep events within 0..7 days (optional: extend if needed)
WHERE e.event_time >= f.first_event_time
AND e.event_time < f.first_event_time + INTERVAL '8 day'
),
cohort_agg AS (
-- base cohort sizes
SELECT
cohort_week,
COUNT(DISTINCT user_id) AS users_in_cohort
FROM first_event
GROUP BY cohort_week
)
SELECT
c.cohort_week,
c.users_in_cohort,
-- day_0 is those with any event on day_offset = 0 (should equal users_in_cohort unless deduped)
COALESCE(d0.day_users, 0) AS day_0_active,
COALESCE(d1.day_users, 0) AS day_1_retention,
COALESCE(d2.day_users, 0) AS day_2_retention,
COALESCE(d3.day_users, 0) AS day_3_retention,
COALESCE(d4.day_users, 0) AS day_4_retention,
COALESCE(d5.day_users, 0) AS day_5_retention,
COALESCE(d6.day_users, 0) AS day_6_retention,
COALESCE(d7.day_users, 0) AS day_7_retention
FROM cohort_agg c
LEFT JOIN (
SELECT cohort_week, COUNT(DISTINCT user_id) AS day_users FROM events_rel WHERE day_offset = 0 GROUP BY cohort_week
) d0 USING (cohort_week)
LEFT JOIN (
SELECT cohort_week, COUNT(DISTINCT user_id) AS day_users FROM events_rel WHERE day_offset = 1 GROUP BY cohort_week
) d1 USING (cohort_week)
LEFT JOIN (
SELECT cohort_week, COUNT(DISTINCT user_id) AS day_users FROM events_rel WHERE day_offset = 2 GROUP BY cohort_week
) d2 USING (cohort_week)
LEFT JOIN (
SELECT cohort_week, COUNT(DISTINCT user_id) AS day_users FROM events_rel WHERE day_offset = 3 GROUP BY cohort_week
) d3 USING (cohort_week)
LEFT JOIN (
SELECT cohort_week, COUNT(DISTINCT user_id) AS day_users FROM events_rel WHERE day_offset = 4 GROUP BY cohort_week
) d4 USING (cohort_week)
LEFT JOIN (
SELECT cohort_week, COUNT(DISTINCT user_id) AS day_users FROM events_rel WHERE day_offset = 5 GROUP BY cohort_week
) d5 USING (cohort_week)
LEFT JOIN (
SELECT cohort_week, COUNT(DISTINCT user_id) AS day_users FROM events_rel WHERE day_offset = 6 GROUP BY cohort_week
) d6 USING (cohort_week)
LEFT JOIN (
SELECT cohort_week, COUNT(DISTINCT user_id) AS day_users FROM events_rel WHERE day_offset = 7 GROUP BY cohort_week
) d7 USING (cohort_week)
ORDER BY cohort_week;
Key points and reasoning:- We anchor cohorts to the user's first event (MIN(event_time)) so every user belongs to exactly one cohort.- day_offset computed in full days (floor of seconds/86400) ensures events are bucketed into calendar-relative days since first event.- Use COUNT(DISTINCT user_id) to avoid double-counting multiple events per user on the same day.Edge cases:- Multiple "first" events: MIN(event_time) picks the earliest timestamp. If two identical earliest timestamps exist, both are equivalent — user still in that cohort. If you need deterministic tie-breakers by event_name, include ROW_NUMBER() over (user_id ORDER BY event_time, event_name) and pick row_number = 1.- Timezone / truncation differences: ensure event_time is normalized to a consistent timezone before DATE_TRUNC.- Users with no subsequent events: retention days >0 will be zero for those users.- Very late event ingestion (backfilled events) can move a user's first_event earlier; consider using ingestion_time filters or snapshotting first_event at cohort calculation time.- Week boundary definition: DATE_TRUNC('week', ...) defaults may vary; clarify whether week starts on Monday or Sunday and adjust (e.g., use DATE_TRUNC('week', event_time, 'MONDAY') in some SQL dialects).Alternatives:- Pivot with conditional aggregation instead of multiple joins: COUNT(DISTINCT CASE WHEN day_offset = 1 THEN user_id END) AS day_1_retention- For large datasets, compute events_rel only for day_offset <=7 to reduce data scanned; use pre-aggregated event-days table for performance.