Approach:1. Build month-level cohorts (month = first day of month) of customers who purchased each category.2. For each category and month, count unique customers in month N-1 and how many of those did NOT purchase in month N.3. Compute churn% = (churned_customers / customers_prev_month) * 100. Handle months with zero previous customers.SQL (Postgres / Redshift / Snowflake style; adjust DATE_TRUNC if needed):sql
WITH orders_cat AS (
SELECT
o.customer_id,
p.category_id,
DATE_TRUNC('month', o.order_date)::date AS month
FROM orders o
JOIN products p USING (product_id)
WHERE o.order_date IS NOT NULL
),
unique_month_customers AS (
-- distinct customers per category-month
SELECT DISTINCT customer_id, category_id, month
FROM orders_cat
),
customers_by_month AS (
SELECT
category_id,
month,
COUNT(*) AS customers_prev_month -- we'll treat this as "customers in that month" then shift
FROM unique_month_customers
GROUP BY category_id, month
),
prev_current AS (
-- for each category-month (current = month), get customers in prev month and whether they appear in current
SELECT
curr.category_id,
curr.month AS month,
prev.customers_prev_month AS customers_prev_month,
COUNT(prev_c.customer_id) FILTER (WHERE curr_c.customer_id IS NULL) AS churned_customers
FROM
-- months present as "current"
(SELECT DISTINCT category_id, month FROM unique_month_customers) curr
LEFT JOIN
-- prev month counts
customers_by_month prev
ON prev.category_id = curr.category_id
AND prev.month = curr.month - INTERVAL '1 month'
-- expand prev-month customers to check membership in current month
LEFT JOIN unique_month_customers prev_c
ON prev_c.category_id = curr.category_id
AND prev_c.month = prev.month
LEFT JOIN unique_month_customers curr_c
ON curr_c.category_id = curr.category_id
AND curr_c.month = curr.month
AND curr_c.customer_id = prev_c.customer_id
GROUP BY curr.category_id, curr.month, prev.customers_prev_month
)
SELECT
category_id,
month,
COALESCE(customers_prev_month, 0) AS customers_prev_month,
COALESCE(churned_customers, 0) AS churned_customers,
CASE
WHEN customers_prev_month IS NULL OR customers_prev_month = 0 THEN NULL
ELSE ROUND(100.0 * churned_customers / customers_prev_month, 2)
END AS churn_pct
FROM prev_current
ORDER BY category_id, month;
Key points and reasoning:- Use DISTINCT customer per month to avoid double-counting repeat purchases.- Shift months by 1 to compare N-1 → N.- Return NULL for churn% where there were zero customers in previous month (avoids misleading 0%).- Round for presentation.Edge cases:- First month in data has no prior month — report churn as NULL.- Customers buying multiple times in a month must be deduplicated.- Category changes over time (if product’s category_id changes historically) — join snapshot of products valid at order time or use historical product table.- Timezones and order_date granularity — normalize to UTC and use order_date timestamp -> date.- Returns/refunds: if churn should consider net purchases, exclude refunded orders or use a transactions table with net quantity.- Categories with sparse data: consider aggregating to weekly/quarterly for stability.