Customer retention (quarter over quarter)¶
Of the customers who bought last quarter, how many bought again this quarter?
Current value (customers case data): 62.8% - ✓ On target. Open the case.
| Field | Value |
|---|---|
| Definition | Customers active in both the previous and the current period divided by customers active in the previous period. |
| Formula | /active(t-1) ∩ active(t)/ / /active(t-1)/ |
| Unit | % |
| Granularity | Customer x quarter. |
| Direction | Higher is better ↑ |
| Target and thresholds | ≥ 50.0% (amber ≥ 40.0%) |
| Parameters | period = QUARTER |
| Owner | Customer Success / CRM Lead |
| Source | Transactions (UCI Online Retail II) |
| Input | table sales, columns customer_id, invoice_date |
| Recommended chart | line - Quarterly trend; a cohort heatmap is the deeper view. |
| Related KPIs | Repeat purchase rate, Revenue at risk (RFM "At risk") |
How to read it¶
The base of recurring revenue. Read it by acquisition cohort: a stable overall rate can hide new cohorts churning faster.
Common misreadings¶
- Period length changes the number completely (monthly < quarterly < yearly); always state the period.
- Anonymous transactions (no customer id) are excluded - if that share changes, retention moves for no business reason.
Worked example (from the test suite)¶
| customer_id | invoice_date |
|---|---|
| a | 2024-01-15 |
| b | 2024-02-01 |
| c | 2024-03-30 |
| d | 2024-03-01 |
| a | 2024-04-02 |
| c | 2024-05-20 |
| e | 2024-06-01 |
Parameters: period = QUARTER
Expected result: 50.00%. Q1 customers: a, b, c, d. Q2 customers: a, c, e. Retained = a, c. 2 / 4 = 0.5 (new customer e does not count).
This example is asserted in tests/test_formulas.py against the Python function.
Implementations¶
Python function (the single implementation used by the site):
@kpi("retention_rate")
def retention_rate(df: pd.DataFrame, period: str) -> float:
"""Retention between the last two periods present in `df`."""
periods = df["invoice_date"].dt.to_period(PANDAS_PERIOD[period])
present = sorted(periods.unique())
if len(present) < 2:
return float("nan")
prev, curr = present[-2], present[-1]
prev_customers = set(df.loc[periods.eq(prev), "customer_id"])
curr_customers = set(df.loc[periods.eq(curr), "customer_id"])
return safe_ratio(len(prev_customers & curr_customers), len(prev_customers))