Quick answer

A cohort retention analysis groups customers by when they started (the cohort), then shows what share of each group was active one month, two months, three months later. The result is a triangle-shaped table: one row per cohort, one column per month since signup, each cell the active count as a percentage of that cohort's starting size.

To read it, compare down a column (is month-1 retention improving for newer cohorts?) and across a row (where does a cohort stop losing people?).

How to Build the Table in Four Steps

  1. Define the cohort. Usually the month of first purchase or signup. One row per cohort.
  2. Define "active." A purchase, a login, or a paid subscription in that month. Pick one definition and keep it, because every cell depends on it.
  3. Index time from each customer's own start. Month 0 is the cohort month; month 1 is the next, and so on. This is what lines cohorts up so they can be compared.
  4. Divide by cohort size. Cell = customers active in month k / customers in the cohort. Month 0 is 100% by construction.

A Worked Example

Question: Did month-one activity differ between the earliest and a later signup group, when both had a month-one result?

Inputs: Simulated activity for four monthly signup groups, with 60 signups in each group, drawn at random through April 2025. Each month is drawn on its own, so a customer can be active in month 2 and not in month 1. The cells therefore show the share active in that month, not the share still a customer. The seed and activity chances are set in the script.

Method: Count each customer once in each month since signup. Divide each count by that group's starting size. Leave later months blank when they have not been seen yet.

import numpy as np
import pandas as pd

SEED = 2026
rng = np.random.default_rng(SEED)
cohorts = pd.period_range("2025-01", periods=4, freq="M")
cohort_size = 60
last_month = pd.Period("2025-04", freq="M")
# Chosen chances are part of the simulation, not real customer data.
monthly_activity_chance = [0.50, 0.45, 0.55, 0.60]

rows = []
for cohort_index, cohort in enumerate(cohorts):
    for customer_num in range(cohort_size):
        customer_id = f"{cohort}-{customer_num:03d}"
        # A signup is active in its cohort month by definition.
        rows.append((customer_id, cohort, cohort))
        month = cohort + 1
        while month <= last_month:
            chance = monthly_activity_chance[cohort_index]
            if rng.random() < chance:
                rows.append((customer_id, cohort, month))
            month += 1

events = pd.DataFrame(rows, columns=["customer_id", "cohort", "month"])
events["age"] = (events["month"].astype("int64")
                 - events["cohort"].astype("int64"))

active = (events.groupby(["cohort", "age"])["customer_id"]
          .nunique()
          .unstack())
# Reindex shows all possible ages; future cells stay NaN.
n_ages = (last_month - cohorts[0]).n + 1
active = active.reindex(columns=range(n_ages))
sizes = (events.loc[events["age"] == 0]
         .groupby("cohort")["customer_id"].nunique())
share = active.div(sizes, axis=0) * 100

print(f"Simulated data, seed {SEED}")
print("Input: 4 monthly cohorts, 60 signups each, to 2025-04.")
print("Built in: chance of being active in each later month is")
print("0.50, 0.45, 0.55, 0.60 for the four cohorts, in order.")
print("Method: count distinct active customers by cohort and")
print("month age; divide by cohort size.")
print("Table: active count / cohort size (active share).")
print("-- means the month has not been observed yet.")
for cohort in cohorts:
    fields = []
    for age in range(active.shape[1]):
        if pd.notna(active.loc[cohort, age]):
            count = int(active.loc[cohort, age])
            size = int(sizes.loc[cohort])
            pct = share.loc[cohort, age]
            fields.append(f"{count}/{size} ({pct:.1f}%)")
        else:
            fields.append("--")
    print(f"{cohort}: " + " | ".join(fields))
first = share.loc[cohorts[0], 1]
later = share.loc[cohorts[2], 1]
print(f"Result: month-1 active share was {first:.1f}% for "
      f"{cohorts[0]} and {later:.1f}% for {cohorts[2]}.")
print("Limit: random simulated activity; it does not show how")
print("real customers behave or why cohorts differ.")
Simulated data, seed 2026
Input: 4 monthly cohorts, 60 signups each, to 2025-04.
Built in: chance of being active in each later month is
0.50, 0.45, 0.55, 0.60 for the four cohorts, in order.
Method: count distinct active customers by cohort and
month age; divide by cohort size.
Table: active count / cohort size (active share).
-- means the month has not been observed yet.
2025-01: 60/60 (100.0%) | 31/60 (51.7%) | 28/60 (46.7%) | 32/60 (53.3%)
2025-02: 60/60 (100.0%) | 26/60 (43.3%) | 24/60 (40.0%) | --
2025-03: 60/60 (100.0%) | 36/60 (60.0%) | -- | --
2025-04: 60/60 (100.0%) | -- | -- | --
Result: month-1 active share was 51.7% for 2025-01 and 60.0% for 2025-03.
Limit: random simulated activity; it does not show how
real customers behave or why cohorts differ.

Result: The active share in month one was 51.7% for January 2025 and 60.0% for March 2025. The activity chances were built in at 0.50 and 0.55, so the built-in gap is 5 points and the run shows 8.3. A gap this size is inside chance with 60 signups per group, so it is not a finding. The table shows the active count, group size and share for each observed cell. The January row is flat (51.7%, 46.7%, 53.3%) because each month was drawn independently, unlike a real decay curve.

Limit: This is random simulated activity. It does not show how real customers behave or why the groups differ.

How to Read It

Read along...What you learnIn the example
A column (down)Whether newer cohorts retain better at the same ageIn the example, month-one active share is 51.7% for January and 60.0% for March. That gap is inside chance at this size, and the table alone does not show an improvement or its cause.
A row (across)The shape of one cohort's decay and where it flattensThe simulated January row reads 51.7%, 46.7%, 53.3%. It does not fall, because each month was drawn on its own.
The diagonalWhat happened in a calendar month across all cohortsA dip on one diagonal can prompt a check for a shared event. The table alone does not show its cause.

The usual reading error is to compare the percentages of cohorts at different ages. Compare like with like: month 2 of January against month 2 of February, never month 4 of January against month 1 of April.

Building It in Python

import pandas as pd

# orders.csv: customer_id, order_date
o = pd.read_csv("orders.csv", parse_dates=["order_date"])
o["month"] = o.order_date.dt.to_period("M")

# cohort = month of first order; age = whole months since then
o["cohort"] = o.groupby("customer_id")["month"].transform("min")
o["age"] = o["month"].astype("int64") - o["cohort"].astype("int64")

active = (o.groupby(["cohort", "age"])["customer_id"]
          .nunique()
          .unstack())
max_month = o["month"].max()
last_age = (max_month - o["cohort"].min()).n
active = active.reindex(columns=range(last_age + 1))
for cohort in active.index:
    seen_age = (max_month - cohort).n
    active.loc[cohort, :seen_age] = active.loc[cohort, :seen_age].fillna(0)

retention = active.div(active[0], axis=0)      # divide by cohort size
print((retention * 100).round(0))

Future cells stay blank. A month inside the observed date range with no active customers is shown as zero, since that month has been observed.

Common Mistakes

  • Treating "not yet happened" as zero. The empty lower-right triangle is missing data, not lost customers.
  • Cohorts too small to read. A cohort of 30 people moves several points on a single customer. With small cohorts, show the counts next to the percentages.
  • Averaging retention across cohorts of different ages. Weight by cohort size and only average cells of the same age.
  • Using revenue and customers interchangeably. Customer retention can fall while revenue retention rises if the survivors spend more. Build both when it matters.

To turn a retention curve into a single churn figure, see the churn rate formula. For the wider method, including segment cohorts, see cohort analysis.

Frequently Asked Questions

How do you calculate cohort retention?

Assign each customer to the cohort of their first purchase or signup month. For each cohort and each month since signup, divide the number of customers who were active in that month by the number of customers in the cohort.

How do you interpret a cohort analysis?

Read down a column to see whether newer cohorts retain better at the same age, and across a row to see how one cohort decays and where it flattens. A change that shows up in every cohort in the same calendar month can prompt a check for a shared event.

What is a good cohort size?

Large enough that one customer does not move a cell by a visible amount. With small cohorts, show the counts next to the percentages.

What is the difference between cohort retention and churn rate?

Churn rate is one number for a period: customers lost divided by customers at the start. A cohort retention table shows the whole curve for each signup group, which is how you see whether churn is concentrated early in a customer's life.

Why does the cohort table look like a triangle?

Newer cohorts have not had as many months to age, so their later columns do not exist yet. Those cells are missing data, not zero retention.