Average retention hides the truth. A blended 30% retention rate can mean your newest users leave faster than ever while a few loyal early adopters prop up the number. Cohort analysis fixes this by grouping users by a shared starting event, such as signup month or first purchase, and tracking each group’s behavior over time. This guide builds a complete retention pipeline in Pandas, from raw event logs to a heatmap you can put in front of stakeholders.
Quick Takeaways
- Cohort analysis groups users by a shared start date and compares behavior at identical lifecycle ages (Month 0, Month 1, Month 2).
- The core output is a retention matrix: rows are cohorts, columns are periods since acquisition, values are the share of users still active.
- Three Pandas operations do most of the work:
groupby().transform('min'),groupby().nunique(), andpivot(). - Read the matrix three ways: down columns (are newer cohorts better?), across rows (how fast does each cohort decay?), and along the diagonal (did a calendar event change behavior?).
| Question | Cohort Type | Metric |
|---|---|---|
| Is onboarding improving? | Acquisition (signup month) | % active at Month 1 |
| Which channel retains best? | Behavioral / Segment | % active at Month 3 |
| Are customers spending more over time? | Revenue cohort | Net revenue retention |
| Did a feature launch help? | Event-based | Retention lift vs. control |
What Is Cohort Analysis?
A cohort is a group of users who share a defining characteristic within a specific time window. The most common definition is the acquisition cohort: all users whose first activity happened in the same month.
Cohort analysis solves a problem called Simpson’s-style aggregation bias. When you pool all users together, your growth rate masks decay. If you add 10,000 new users per month, overall active users keep rising even when each cohort loses 80% of its members by Month 3. The cohort view separates acquisition volume from retention quality.
Three terms matter for everything that follows:
- Cohort month: The period in which a user first appeared.
- Period number (cohort age): Months elapsed since the cohort month. Month 0 is always 100% by definition.
- Retention rate:
active_users(cohort, period) / cohort_size.
Cohort Types Compared
| Cohort Type | Grouping Rule | Best For | Limitation |
|---|---|---|---|
| Acquisition | First event date | Product health, onboarding quality | Ignores user intent |
| Behavioral | Action taken (e.g., used Feature X) | Feature impact | Selection bias |
| Segment | Attribute (channel, plan, region) | Marketing ROI | Needs clean attribution data |
| Size-based | Spend or usage tier | Whale vs. casual analysis | Tier boundaries are arbitrary |
Step 1: Prepare the Event Data
You need only two columns: a user identifier and an event timestamp. The code below generates a synthetic event log so you can run everything end to end. Replace it with your own table in production.
import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
def make_events(n_users=5000, start="2025-01-01", span_days=360,
cutoff="2026-06-30", seed=42):
"""Generate a synthetic activity log with decaying monthly retention."""
rng = np.random.default_rng(seed)
start = pd.Timestamp(start)
signups = start + pd.to_timedelta(rng.integers(0, span_days, n_users), unit="D")
rows = []
for uid, signup in enumerate(signups):
rows.append((uid, signup)) # first event = signup
p = 0.55 # Month 1 return probability
for m in range(1, 12):
if rng.random() < p:
offset = pd.DateOffset(months=m) + pd.Timedelta(days=int(rng.integers(0, 28)))
rows.append((uid, signup + offset))
p *= 0.88 # decay per month
df = pd.DataFrame(rows, columns=["user_id", "event_date"])
return df[df["event_date"] <= pd.Timestamp(cutoff)] # drop "future" events
df = make_events()
print(df.shape)
df.head()
Real data needs three checks before you proceed:
- Timestamps are timezone-consistent. Convert everything to UTC with
pd.to_datetime(col, utc=True). - One identity per person. Duplicate user IDs across devices inflate cohort sizes and deflate retention.
- Define “active.” A login, a purchase, and a session over 30 seconds produce three different retention curves. Pick one and document it.
Step 2: Assign Cohort Month and Period Number
Two derived columns drive the whole analysis. cohort_month is each user’s earliest event. period_number is the gap between an event’s month and that cohort month.
# Truncate timestamps to month start for clean grouping
df["event_month"] = df["event_date"].dt.to_period("M")
# Each user's first-ever event month = their cohort
df["cohort_month"] = (
df.groupby("user_id")["event_date"].transform("min").dt.to_period("M")
)
# Months elapsed since cohort month (integer lifecycle age)
df["period_number"] = (
(df["event_month"].dt.year - df["cohort_month"].dt.year) * 12
+ (df["event_month"].dt.month - df["cohort_month"].dt.month)
)
The transform("min") call returns a Series aligned to the original index. That alignment lets you assign it directly as a column without a merge. Computing period_number with integer arithmetic on year and month is also faster than applying a lambda to Period objects across millions of rows.
Step 3: Build the Retention Matrix
Count distinct active users per cohort and period. Using nunique matters: a user with five events in one month still counts once.
cohort_counts = (
df.groupby(["cohort_month", "period_number"])["user_id"]
.nunique()
.reset_index(name="active_users")
)
# Wide format: rows = cohorts, columns = period numbers
active_matrix = cohort_counts.pivot(
index="cohort_month", columns="period_number", values="active_users"
)
# Cohort size is the Month 0 column
cohort_size = active_matrix[0]
# Divide each row by its cohort size to get retention rates
retention = active_matrix.divide(cohort_size, axis=0).round(3)
print(retention.iloc[:6, :7])
The result is a triangular table. Older cohorts have more columns filled because they have had more time to age. Missing cells in the lower right are right-censored: the data window has not reached that period yet. Never fill them with zeros, because a zero implies churn rather than missing time.
Step 4: Visualize with a Heatmap
A heatmap turns a 12-by-12 grid of numbers into a pattern you can read in seconds.
fig, ax = plt.subplots(figsize=(13, 7))
sns.heatmap(
retention,
annot=True, # print the rate inside each cell
fmt=".0%", # show as percentage
cmap="YlGnBu",
vmin=0, vmax=0.7, # fix the scale so colors stay comparable across runs
linewidths=0.5,
cbar_kws={"label": "Retention rate"},
ax=ax,
)
ax.set_title("Monthly Cohort Retention")
ax.set_xlabel("Months Since Signup")
ax.set_ylabel("Signup Cohort")
ax.set_yticklabels([str(c) for c in retention.index], rotation=0)
plt.tight_layout()
plt.show()
Set vmin and vmax explicitly. Without them, the color scale rescales to each dataset, and last quarter’s heatmap becomes impossible to compare with this quarter’s.
Step 5: Extract Insights from the Matrix
Raw heatmaps invite eyeballing. Quantify the patterns instead.
# Weighted average retention curve (weights = cohort size)
weights = cohort_size.values[:, None]
valid = active_matrix.notna().values
weighted_curve = (
(active_matrix.fillna(0).values).sum(axis=0)
/ (weights * valid).sum(axis=0)
)
avg_curve = pd.Series(weighted_curve, index=active_matrix.columns, name="weighted_retention")
# Month-1 retention trend: is onboarding improving?
m1_trend = retention[1].dropna()
# Month-over-month decay rate within the average curve
decay = avg_curve.pct_change().round(3)
print(avg_curve.round(3).head(8))
print("\nMonth-1 retention by cohort:\n", m1_trend)
The weighted average matters. A simple mean treats a 200-user cohort the same as a 2,000-user cohort, which over-weights noisy small groups.
How to Read the Three Axes
| Reading Direction | Question It Answers | What to Look For |
|---|---|---|
| Down a column | Are newer cohorts better or worse? | Improving or degrading Month-1 values |
| Across a row | How fast does one cohort decay? | Steep early drop, then a plateau |
| Along a diagonal | Did a calendar event shift behavior? | A color shift hitting several cohorts in the same month |
A plateau is the most important shape. If curves flatten at 15% to 25%, you have a loyal core and your retention problem lives in the first 30 days. If curves keep falling toward zero, you have a product-market fit problem that acquisition spend cannot fix.
Retention Metrics: Which One Fits?
| Metric | Definition | Strength | Weakness |
|---|---|---|---|
| Classic (N-day) retention | Active on exactly day N | Precise, standard for apps | Penalizes irregular-use products |
| Rolling (unbounded) retention | Active on day N or any later day | Forgives gaps | Overstates retention for old cohorts |
| Range retention | Active within a window (e.g., days 7-14) | Fits weekly-use products | Window choice is subjective |
| Net revenue retention (NRR) | Revenue from cohort vs. its starting revenue | Captures expansion | Dominated by a few large accounts |
Pick the metric that matches your product’s natural usage frequency. A meal-kit service should use monthly range retention. A messaging app should use daily classic retention.
Revenue Cohorts: Extending the Pipeline
Swap the distinct-user count for a revenue sum to measure net revenue retention per cohort. This assumes your frame has an amount column.
# Assumes df has columns: user_id, event_date, amount
df["amount"] = np.random.default_rng(0).gamma(2.0, 20.0, len(df)) # demo values only
revenue = (
df.groupby(["cohort_month", "period_number"])["amount"]
.sum()
.unstack("period_number")
)
# NRR: revenue at each period relative to Month 0 revenue
nrr = revenue.divide(revenue[0], axis=0).round(3)
print(nrr.iloc[:4, :6])
When NRR exceeds 100%, surviving customers spend enough to offset churned ones. That is the signature of a healthy subscription or expansion-driven business.
Doing It in SQL
Warehouse-scale data often should not leave the database. The same logic in standard SQL:
WITH first_activity AS (
SELECT user_id,
DATE_TRUNC('month', MIN(event_date)) AS cohort_month
FROM events
GROUP BY user_id
),
activity AS (
SELECT DISTINCT
e.user_id,
f.cohort_month,
DATE_DIFF('month', f.cohort_month,
DATE_TRUNC('month', e.event_date)) AS period_number
FROM events e
JOIN first_activity f USING (user_id)
)
SELECT cohort_month,
period_number,
COUNT(DISTINCT user_id) AS active_users
FROM activity
GROUP BY 1, 2
ORDER BY 1, 2;
Date function names vary by engine (DATEDIFF in Snowflake and BigQuery has a different argument order). Run the aggregation in SQL, then pivot in Pandas.
Real-World Use Cases
Predicting Churn Risk in a SaaS Product
A B2B SaaS team notices Month-3 retention dropped from 41% to 29% over four cohorts. Segmenting the cohorts by acquisition channel shows the decline sits entirely in paid-social signups. Organic and referral cohorts held steady. The fix is a targeting change, not a product change.
Measuring a Feature Launch in E-commerce
A retailer launches a loyalty program in June. The heatmap shows a diagonal shift: every cohort active in June and later shows higher repeat-purchase rates, regardless of signup date. That diagonal pattern separates a calendar effect from a cohort-quality effect. It argues the loyalty program worked on existing customers, not just new ones.
Diagnosing Onboarding in a Mobile App
A subscription app tracks day-1, day-7, and day-30 classic retention by install week. Cohorts that completed a three-step tutorial retain at nearly double the rate of those who skipped it. The team treats tutorial completion as an activation metric and redesigns the first-run screen to push it.
Common Pitfalls
- Censoring errors. Comparing Month-6 retention across cohorts when the newest cohort has not reached Month 6 produces false declines. Mask incomplete periods.
- Tiny cohorts. Cohorts under roughly 100 users produce volatile rates. Merge them into quarterly cohorts or show confidence intervals.
- Survivorship in averages. Averaging across periods where only old cohorts have data skews the curve upward. Use the weighted approach shown above.
- Moving definitions. Changing what counts as “active” mid-analysis breaks comparability across the whole matrix.
Frequently Asked Questions
What is the difference between cohort analysis and segmentation?
Segmentation splits users by who they are (country, plan, device) at one point in time. Cohort analysis splits users by when they started and follows them over time. The two combine well: build separate retention matrices for each segment to see which audience retains best.
How many months of data do I need for cohort analysis?
Six to twelve months gives a reliable retention curve for most subscription products. You need enough history for the curve to reach a plateau. Faster-cycle products such as mobile games can work with 30 to 90 days using daily cohorts.
What is a good retention rate?
It depends on the category. Consumer mobile apps commonly see Day-30 retention in the low teens or below, while B2B SaaS often retains well above 80% annually. Benchmark against your own earlier cohorts first. The trend across cohorts says more than any industry average.
Should I use daily, weekly, or monthly cohorts?
Match the cohort window to your usage cycle. Use daily for apps people open every day, weekly for products with weekly rhythms, and monthly for subscriptions and e-commerce. Shorter windows add detail but shrink cohort sizes and increase noise.



