Skip to content

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))