Functional Weave
Code in Python

subscriptions.churn

Logo churn, gross and net revenue churn, and gross and net revenue retention for one period, in basis points.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 15 tests, run in TypeScript, Python and Rust.

What it does

The standard SaaS churn and retention rates for one period (usually a month), all measured against the customers and MRR that existed at the start of it:

logo churn = customersLost / customersAtStart gross revenue churn = (contraction + churned) / mrrAtStart net revenue churn = (contraction + churned - expansion) / mrrAtStart gross revenue retention = (mrrAtStart - contraction - churned) / mrrAtStart net revenue retention = (mrrAtStart + expansion - contraction - churned) / mrrAtStart

For example

  • churn_metrics(200, 7, £10,000.00, £500.00, £200.00, £300.00) → logo churn basis points 3.5%, gross revenue churn basis points 5%, net revenue churn basis points 0%, gross revenue retention basis points 95%, net revenue retention basis points … a typical month: expansion exactly offsets losses, so net churn is zero and NRR 100%
  • churn_metrics(50, 5, £100.00, £10.00, £10.00, £10.00) → logo churn basis points 10%, gross revenue churn basis points 20%, net revenue churn basis points 10%, gross revenue retention basis points 80%, net revenue retention basis points… ChartMogul's example: 100 + 10 - 10 - 10 is 90% net retention
  • churn_metrics(40, 1, £1,000.00, £150.00, £20.00, £30.00) → logo churn basis points 2.5%, gross revenue churn basis points 5%, net revenue churn basis points -10%, gross revenue retention basis points 95%, net revenue retention basis point… negative churn: expansion beats losses, NRR above 100%

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def churn_metrics(customers_at_start: int, customers_lost: int, mrr_at_start: Money, expansion_mrr: Money, contraction_mrr: Money, churned_mrr: Money) -> ChurnMetrics
customers_at_startintpaying customers at the start of the period
customers_lostintof those, how many had cancelled by its end; customers who joined during the period are not counted
mrr_at_startMoneyMRR from the customers at the start of the period
expansion_mrrMoneyMRR added by those customers during the period (upgrades, seats, reactivations if you count them)
contraction_mrrMoneyMRR lost to downgrades by customers who stayed
churned_mrrMoneyMRR lost with the customers who cancelled
returnsChurnMetrics

The type it declares, generated into your project

@dataclass(frozen=True)
class ChurnMetrics:
    """Rates in basis points (10000 is 100%), rounded half-up; null when the period started with nothing to measure against."""

    #: customersLost / customersAtStart
    logo_churn_basis_points: Optional[int]
    #: (contraction + churned) / mrrAtStart
    gross_revenue_churn_basis_points: Optional[int]
    #: (contraction + churned - expansion) / mrrAtStart; negative when expansion wins
    net_revenue_churn_basis_points: Optional[int]
    #: (mrrAtStart - contraction - churned) / mrrAtStart
    gross_revenue_retention_basis_points: Optional[int]
    #: (mrrAtStart + expansion - contraction - churned) / mrrAtStart
    net_revenue_retention_basis_points: Optional[int]

Your code names it in one line, in the file that uses it

from fune.subscriptions.churn import churn_metrics  # subscriptions.churn@^1
impl/python.py · 54 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from typing import Optional

from .math_round_div import round_div  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import Money, assert_same_currency  ← from money.amount ^1.0.0 · built alongside by fune
from .subscriptions_churn_types import ChurnMetrics


def _rate(numerator: int, denominator: int) -> Optional[int]:
    return None if denominator == 0 else round_div(numerator * 10000, denominator, "half-up")


def _is_count(value: object) -> bool:
    return not isinstance(value, bool) and isinstance(value, int) and value >= 0


def churn_metrics(
    customers_at_start: int,
    customers_lost: int,
    mrr_at_start: Money,
    expansion_mrr: Money,
    contraction_mrr: Money,
    churned_mrr: Money,
) -> ChurnMetrics:
    """Logo churn, gross and net revenue churn, and gross and net revenue
    retention for one period, each as basis points of the starting cohort.
    """
    if not _is_count(customers_at_start) or not _is_count(customers_lost):
        raise ValueError(
            "customer counts must be whole numbers of 0 or more, received %r and %r"
            % (customers_at_start, customers_lost)
        )
    if customers_lost > customers_at_start:
        raise ValueError(
            "customers lost must not exceed customers at start, received %d of %d"
            % (customers_lost, customers_at_start)
        )
    for amount in (expansion_mrr, contraction_mrr, churned_mrr):
        assert_same_currency(mrr_at_start, amount)
    start = mrr_at_start.minor
    expansion = expansion_mrr.minor
    lost = contraction_mrr.minor + churned_mrr.minor
    if start < 0 or expansion < 0 or contraction_mrr.minor < 0 or churned_mrr.minor < 0:
        raise ValueError("MRR amounts must not be negative")
    if lost > start:
        raise ValueError(
            "contraction and churned MRR must not exceed MRR at start, received %d of %d" % (lost, start)
        )
    return ChurnMetrics(
        logo_churn_basis_points=_rate(customers_lost, customers_at_start),
        gross_revenue_churn_basis_points=_rate(lost, start),
        net_revenue_churn_basis_points=_rate(lost - expansion, start),
        gross_revenue_retention_basis_points=_rate(start - lost, start),
        net_revenue_retention_basis_points=_rate(start + expansion - lost, start),
    )

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add subscriptions.churn
Download for Python subscriptions.churn-1.0.0-python.fune · 13,886 bytes sha256 627302ab5055cc112c27c1a3d72fce33e7b4054a0f358b1b97d39688ee1eafcb

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./subscriptions.churn-1.0.0-python.fune, or fetch it from a terminal with fune pull subscriptions.churn@1.0.0:python.

The whole function, every language, is one file too: subscriptions.churn-1.0.0.fune, 19,176 bytes, sha256 2de1c509adb029c3c9ebed57f1d3285cc2bbcaa850afc64282e4384e7ceb12a6. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before subscriptions.churn

after — your function gets the result and the arguments, and returns the final result.

# fune: after subscriptions.churn

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

# fune: replace math.round-div in subscriptions.churn
# fune: replace money.amount in subscriptions.churn

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show subscriptions.churn --steps.

# fune: step subscriptions.churn after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
a typical month: expansion exactly offsets losses, so net churn is zero and NRR 100% 200, 7, £10,000.00, £500.00, £200.00, £300.00 → logo churn basis points 3.5%, gross revenue churn basis points 5%, net revenue churn basis points 0%, gross revenue retention basis points 95%, net revenue retention basis points …
ChartMogul's example: 100 + 10 - 10 - 10 is 90% net retention 50, 5, £100.00, £10.00, £10.00, £10.00 → logo churn basis points 10%, gross revenue churn basis points 20%, net revenue churn basis points 10%, gross revenue retention basis points 80%, net revenue retention basis points…
negative churn: expansion beats losses, NRR above 100% 40, 1, £1,000.00, £150.00, £20.00, £30.00 → logo churn basis points 2.5%, gross revenue churn basis points 5%, net revenue churn basis points -10%, gross revenue retention basis points 95%, net revenue retention basis point…
one lost out of three is 33.33%, rounded to a whole basis point 3, 1, £300.00, £0.00, £0.00, £100.00 → logo churn basis points 33.33%, gross revenue churn basis points 33.33%, net revenue churn basis points 33.33%, gross revenue retention basis points 66.67%, net revenue retention …
two lost out of three rounds up to 66.67% 3, 2, £300.00, £0.00, £0.00, £200.00 → logo churn basis points 66.67%, gross revenue churn basis points 66.67%, net revenue churn basis points 66.67%, gross revenue retention basis points 33.33%, net revenue retention …
half a basis point of net churn rounds away from zero 10, 0, £200.00, £0.01, £0.00, £0.00 → logo churn basis points 0%, gross revenue churn basis points 0%, net revenue churn basis points -0.01%, gross revenue retention basis points 100%, net revenue retention basis poin…
everyone lost: 100% churn and nothing retained 10, 10, £50.00, £0.00, £0.00, £50.00 → logo churn basis points 100%, gross revenue churn basis points 100%, net revenue churn basis points 100%, gross revenue retention basis points 0%, net revenue retention basis poin…
no customers and no MRR at the start: every rate is null, not zero 0, 0, £0.00, £0.00, £0.00, £0.00 → logo churn basis points —, gross revenue churn basis points —, net revenue churn basis points —, gross revenue retention basis points —, net revenue retention basis points —
free customers only: logo churn exists, revenue rates do not 5, 1, £0.00, £0.00, £0.00, £0.00 → logo churn basis points 20%, gross revenue churn basis points —, net revenue churn basis points —, gross revenue retention basis points —, net revenue retention basis points —
a quiet month: nobody left, nothing changed 120, 0, $6,000.00, $0.00, $0.00, $0.00 → logo churn basis points 0%, gross revenue churn basis points 0%, net revenue churn basis points 0%, gross revenue retention basis points 100%, net revenue retention basis points 1…
Show the other 5 tests
CaseArgumentsExpected
more customers lost than there were is an error 5, 6, £10.00, £0.00, £0.00, £0.00 → error: customers lost must not exceed customers at start
a negative customer count is an error -1, 0, £10.00, £0.00, £0.00, £0.00 → error: customer counts must be whole numbers of 0 or more
losing more MRR than there was is an error 5, 1, £10.00, £0.00, £6.00, £5.00 → error: contraction and churned MRR must not exceed MRR at start
a negative expansion is an error 5, 1, £10.00, -£0.10, £0.00, £0.00 → error: MRR amounts must not be negative
amounts in different currencies are an error 5, 1, £10.00, €0.10, £0.00, £0.00 → error: currency mismatch

More from the author

These are ChartMogul's published definitions (Gross MRR Churn Rate, Net MRR Churn Rate and Net Revenue Retention), which are the ones most SaaS reporting uses. Net revenue churn goes negative when expansion from existing customers outweighs what they took away ("negative churn"), and net revenue retention goes above 10000 (100%) in the same case. ChartMogul adds reactivation MRR to expansion in NRR and net churn; if you want the same, pass expansion plus reactivation as `expansionMrr`.

Every input is about the cohort at the start of the period. New customers who joined during it, and the MRR they brought, are left out: they are growth, not retention, and including them is the most common way churn gets under-reported. A customer who joined and cancelled within the period is not in `customersLost` either.

Each rate is one exact division rounded half-up (away from zero for a negative net churn) to a whole basis point, so 1 lost out of 3 is 3333 (33.33%). A rate is null rather than an error when its denominator is zero: a business with no customers at the start of the month has no churn rate, not a churn rate of zero.

Errors: negative counts or amounts, more customers lost than there were, more MRR contracted and churned than there was, and amounts in different currencies.

Sources: ChartMogul, "Revenue churn (net and gross revenue churn rate)", https://chartmogul.com/saas-metrics/revenue-churn/ and "Net Revenue Retention (NRR)", https://chartmogul.com/saas-metrics/nrr/.

Files

PathBytes
README.md2,052
impl/python.py2,210
impl/rust.rs3,098
impl/typescript.ts1,999
vectors.json5,604