Approach: compute each user’s cohort_week (start of week containing their signup), then for each cohort and each week_offset (0..3) count how many users from that cohort had any event in the target week. Divide by cohort size to get retention_rate.SQL (ANSI/Postgres dialect shown; uses date_trunc and FILTER as requested):sql
WITH user_cohorts AS (
SELECT
user_id,
date_trunc('week', MIN(event_date) FILTER (WHERE event_name = 'signup'))::date AS cohort_week
FROM events
GROUP BY user_id
),
cohort_sizes AS (
SELECT cohort_week, COUNT(*) AS cohort_users
FROM user_cohorts
GROUP BY cohort_week
),
week_offsets AS (
-- produce offsets 0..3
SELECT 0 AS week_offset UNION ALL
SELECT 1 UNION ALL
SELECT 2 UNION ALL
SELECT 3
),
cohort_activity AS (
SELECT
uc.cohort_week,
wo.week_offset,
COUNT(DISTINCT uc.user_id) AS active_users
FROM user_cohorts uc
CROSS JOIN week_offsets wo
LEFT JOIN events e
ON e.user_id = uc.user_id
AND e.event_date >= (uc.cohort_week + (wo.week_offset * INTERVAL '1 week'))::date
AND e.event_date < (uc.cohort_week + ((wo.week_offset + 1) * INTERVAL '1 week'))::date
GROUP BY uc.cohort_week, wo.week_offset
)
SELECT
ca.cohort_week,
ca.week_offset,
COALESCE(ca.active_users, 0) AS active_users,
cs.cohort_users,
ROUND((COALESCE(ca.active_users,0)::numeric / cs.cohort_users) * 100, 2) AS retention_rate_pct
FROM cohort_activity ca
JOIN cohort_sizes cs USING (cohort_week)
ORDER BY cohort_week, week_offset;
Key points:- cohort_week computed with date_trunc('week', MIN(event_date) FILTER (WHERE event_name='signup')) per requirement.- Use half-open intervals [start, start + 1 week) to avoid overlap.- DISTINCT user count ensures multiple events in same week count once.- Division by cohort size yields retention rate; multiplied by 100 for pct and rounded.Sample output (two cohorts):cohort_week | week_offset | active_users | cohort_users | retention_rate_pct2025-01-05 | 0 | 120 | 150 | 80.002025-01-05 | 1 | 90 | 150 | 60.002025-01-05 | 2 | 70 | 150 | 46.672025-01-05 | 3 | 60 | 150 | 40.002025-01-12 | 0 | 140 | 160 | 87.502025-01-12 | 1 | 110 | 160 | 68.752025-01-12 | 2 | 95 | 160 | 59.382025-01-12 | 3 | 80 | 160 | 50.00Edge cases:- Users without a signup event are excluded (cohort undefined).- If cohort_week is NULL for some users, filter them out or handle explicitly.- If cohort spans DST boundaries, date_trunc('week') remains consistent when using dates.Alternative: pivot results into matrix columns week_0..week_3 using conditional aggregation for dashboard-friendly shape.