Approach (brief): assign users to weekly signup cohorts, compute each user's churn_time (date when they first became inactive for >=30 days after signup — i.e., the start date of the first 30+ day gap or, if no such gap observed, set churn_time = NULL or large sentinel). For each threshold (0,30,60,90 days), a user “survives” if churn_time is after signup + threshold (or churn_time is NULL/after observation window). Aggregate per cohort to get survival probability = survivors / cohort_size.Key steps / CTEs:1) cohorts: week bucket for signup_date2) ordered_events: for each user, event dates sorted (include signup_date as an event)3) gaps: compute next_event_date and gap_days between events4) churn_point: find first event_date where next_event_date - event_date >= 30 (churn_date = event_date + 30). If no gap >=30, optional set churn_date = NULL or last_event + 30 if you treat end-of-observation as churn5) survival_flags: for each user and each threshold compute survived = churn_date IS NULL OR churn_date > signup_date + threshold6) aggregate per cohort: count survivors and cohort size, compute proportionsMain SQL structure (skeleton):sql
WITH cohorts AS (
SELECT
u.user_id,
u.signup_date,
DATE_TRUNC('week', u.signup_date) AS cohort_week
FROM users u
),
ordered_events AS (
-- include signup as an event to anchor timeline
SELECT
e.user_id,
e.event_date
FROM events e
UNION ALL
SELECT user_id, signup_date AS event_date
FROM users
),
events_with_lag AS (
SELECT
oe.user_id,
oe.event_date,
LEAD(oe.event_date) OVER (PARTITION BY oe.user_id ORDER BY oe.event_date) AS next_event_date
FROM ordered_events oe
),
gaps AS (
SELECT
user_id,
event_date,
next_event_date,
CASE
WHEN next_event_date IS NULL THEN NULL
ELSE DATE_PART('day', next_event_date - event_date)
END AS gap_days
FROM events_with_lag
),
churn_point AS (
-- first event_date where gap >= 30; churn_date = event_date + 30
SELECT
g.user_id,
MIN(g.event_date + INTERVAL '30 day') AS churn_date
FROM gaps g
WHERE g.gap_days >= 30
GROUP BY g.user_id
),
user_churn AS (
-- left join keeps users with no observed churn_date (NULL)
SELECT
c.user_id,
c.cohort_week,
c.signup_date,
cp.churn_date
FROM cohorts c
LEFT JOIN churn_point cp ON c.user_id = cp.user_id
),
survival_flags AS (
SELECT
uc.*,
CASE WHEN uc.churn_date IS NULL OR uc.churn_date > uc.signup_date + INTERVAL '0 day' THEN 1 ELSE 0 END AS survive_0,
CASE WHEN uc.churn_date IS NULL OR uc.churn_date > uc.signup_date + INTERVAL '30 day' THEN 1 ELSE 0 END AS survive_30,
CASE WHEN uc.churn_date IS NULL OR uc.churn_date > uc.signup_date + INTERVAL '60 day' THEN 1 ELSE 0 END AS survive_60,
CASE WHEN uc.churn_date IS NULL OR uc.churn_date > uc.signup_date + INTERVAL '90 day' THEN 1 ELSE 0 END AS survive_90
FROM user_churn uc
),
cohort_survival AS (
SELECT
cohort_week,
COUNT(*) AS cohort_size,
SUM(survive_0)::float / COUNT(*) AS p0,
SUM(survive_30)::float / COUNT(*) AS p30,
SUM(survive_60)::float / COUNT(*) AS p60,
SUM(survive_90)::float / COUNT(*) AS p90
FROM survival_flags
GROUP BY cohort_week
ORDER BY cohort_week
)
SELECT * FROM cohort_survival;
Notes, assumptions and edge cases:- We treat churn as the moment a 30-day inactivity window starts: churn_date = event_date + 30 when next_event - event_date >= 30.- Users with no observed 30+ gap are treated as still active (churn_date = NULL). If you have a limited observation window, you may want to censor users whose last_event is within 30 days of data end — treat them as unknown rather than survived.- If events are sparse / duplicate timestamps, ensure deduplication of event dates.- For very large tables, index on user_id,event_date and push heavy per-user ops into analytic DBs (BigQuery/Redshift) or process in Spark for performance.- You can extend to Kaplan-Meier style life-table by handling censoring explicitly if needed.