Churn Analysis — Rates, Timing, and Leading Indicators
Churn Analysis — Rates, Timing, and Leading Indicators
Churn is the single biggest threat to SaaS revenue. This lesson covers monthly churn rate calculation, time-to-churn distribution, and detecting leading indicators from session and feature data.
1. Monthly Churn Rate
Churn rate = churned accounts / accounts active at start of month:
WITH monthly_counts AS (
SELECT
DATE_TRUNC('month', converted_at)::DATE AS month,
COUNT(*) AS new_customers
FROM saas_accounts
WHERE converted_at IS NOT NULL
GROUP BY 1
),
monthly_churn AS (
SELECT
DATE_TRUNC('month', churned_at)::DATE AS month,
COUNT(*) AS churned
FROM saas_accounts
WHERE churned_at IS NOT NULL
GROUP BY 1
),
active_base AS (
SELECT
mc.month,
SUM(mc2.new_customers) AS active_start
FROM monthly_counts mc
JOIN monthly_counts mc2 ON mc2.month <= mc.month
GROUP BY mc.month
)
SELECT
ab.month,
ab.active_start,
COALESCE(mc.churned, 0) AS churned,
ROUND(100.0 * COALESCE(mc.churned, 0) / NULLIF(ab.active_start, 0), 2) AS churn_rate_pct
FROM active_base ab
LEFT JOIN monthly_churn mc ON mc.month = ab.month
ORDER BY ab.month;
2. Time-to-Churn Distribution
How long did churned customers stay before leaving?
SELECT
DATE_PART('month', AGE(churned_at, converted_at))::INT AS months_to_churn,
COUNT(*) AS accounts,
ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct
FROM saas_accounts
WHERE churned_at IS NOT NULL AND converted_at IS NOT NULL
GROUP BY 1
ORDER BY 1;
What This Returns
months_to_churn | accounts | pct
----------------+----------+-----
2 | 12 | 15.2
3 | 10 | 12.7
4 | 9 | 11.4
...
3. Churn by Plan and Channel
SELECT
p.name AS plan,
a.acquisition_channel,
COUNT(*) FILTER (WHERE a.status = 'active') AS active,
COUNT(*) FILTER (WHERE a.status = 'churned') AS churned,
ROUND(100.0 *
COUNT(*) FILTER (WHERE a.status = 'churned')
/ NULLIF(COUNT(*), 0), 1) AS churn_rate_pct
FROM saas_accounts a
JOIN saas_plans p ON p.id = a.plan_id
GROUP BY p.name, a.acquisition_channel
ORDER BY churn_rate_pct DESC;
4. Leading Indicators — Low Engagement Before Churn
Accounts with low session activity in the 30 days before churning are likely disengaging:
WITH churned AS (
SELECT id AS account_id, churned_at
FROM saas_accounts
WHERE churned_at IS NOT NULL
),
pre_churn_sessions AS (
SELECT
c.account_id,
COUNT(s.id) AS sessions_last_30d,
AVG(s.page_count) AS avg_pages
FROM churned c
LEFT JOIN saas_sessions s
ON s.account_id = c.account_id
AND s.started_at >= c.churned_at - INTERVAL '30 days'
AND s.started_at < c.churned_at
GROUP BY c.account_id
)
SELECT
CASE
WHEN sessions_last_30d = 0 THEN 'No sessions'
WHEN sessions_last_30d <= 2 THEN 'Very low (1-2)'
WHEN sessions_last_30d <= 5 THEN 'Low (3-5)'
ELSE 'Normal (6+)'
END AS pre_churn_engagement,
COUNT(*) AS accounts,
ROUND(AVG(avg_pages), 1) AS avg_pages_per_session
FROM pre_churn_sessions
GROUP BY 1
ORDER BY accounts DESC;
5. Feature Usage as Churn Predictor
Accounts using zero features in a month are high churn risk:
WITH last_month_usage AS (
SELECT
a.id AS account_id,
a.status,
SUM(fu.usage_count) AS total_usage
FROM saas_accounts a
LEFT JOIN saas_feature_usage fu
ON fu.account_id = a.id
AND fu.period_date = DATE_TRUNC('month', NOW() - INTERVAL '1 month')::DATE
WHERE a.converted_at IS NOT NULL
GROUP BY a.id, a.status
)
SELECT
CASE WHEN total_usage = 0 OR total_usage IS NULL THEN 'Zero usage' ELSE 'Active user' END AS usage_category,
COUNT(*) AS accounts,
COUNT(*) FILTER (WHERE status = 'churned') AS churned,
ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'churned')
/ NULLIF(COUNT(*), 0), 1) AS churn_rate_pct
FROM last_month_usage
GROUP BY 1;
Key Takeaway
Churn rate calculation requires careful denominator selection — "accounts active at start of month" avoids the survivorship bias of using end-of-month count. The pre-churn engagement pattern (joining to activity in the N days before churned_at) is the foundation of early-warning churn models and feeds into customer success playbooks.