Skip to content

Free Claude skill · Metrics and investor updates · v1.0

SaaS metrics analysis skill for Claude

Give Claude a CSV of monthly recurring revenue by customer and it builds the MRR bridge month by month: new, expansion, contraction, churned and reactivated revenue. It reports MRR, ARR, logo churn, revenue churn, net and gross revenue retention and ARPA, and with your spend, margin and cash figures it adds LTV:CAC, CAC payback, runway and burn multiple. A script does every calculation, using the same definitions as StartupReader's calculators.

By the StartupReader editorial desk · Updated 2026-11-08 · MIT licence · Works on claude.ai, Claude Code and the Claude API

At a glance2026

SaaS metrics analysis

MRR movements it tracks
5
efficiency ratios
7
network calls
0
MIT licence
Free
SaaS metrics analysis at a glance: MRR movements it tracks: 5; efficiency ratios: 7; network calls: 0; MIT licence: Free.

What it does

  • Reads an MRR export in long or wide format, maps unfamiliar column names, and refuses ambiguous dates, gaps and negative MRR instead of guessing.
  • Classifies every customer's change each month as new, expansion, contraction, churned or reactivation, and builds the MRR bridge from it.
  • Reports ending MRR, ARR, customers, logo churn, gross revenue churn, net and gross revenue retention and ARPA for every month.
  • Works out cohort NRR and GRR for the customers you had at the start of the period, and compounds monthly churn into an annual figure.
  • Adds CAC, LTV, LTV:CAC, CAC payback, runway, burn multiple and the Rule of 40 when you give it spend, gross margin and cash figures.
  • Lists which customers moved in any month, so a bad month can be traced to the accounts behind it.

Ask Claude things like

  • “Here's our MRR export from the billing system. What's our churn and net revenue retention?”
  • “Build me an MRR bridge for the last six months from this spreadsheet.”
  • “Is our LTV to CAC ratio healthy? We spent $18k on sales and marketing last quarter.”
  • “Why did MRR drop in October? Which customers churned or downgraded?”

What you give it

  • A CSV of MRR by customer and month, from your billing tool or a spreadsheet. Invoices or subscription lists work too: Claude converts them to monthly MRR first and tells you what it changed.
  • Optionally, sales and marketing spend and the months it covers, and your gross margin, for CAC, LTV and payback.
  • Optionally, cash in the bank and average monthly net burn, for runway and burn multiple.

What you get back

  • A headline: MRR and ARR now, how fast they grew, and the biggest leak.
  • The MRR bridge for recent months, and a retention table with logo churn, revenue churn, NRR and GRR.
  • Unit economics and efficiency ratios, each set against a rule of thumb from StartupReader's explainers.
  • The customers behind the months that stand out, and up to three things to look at next.
  • The definitions it used, so the numbers can be checked and compared next month.

How each month is classified

The script compares every customer's MRR with the month before. Revenue from a customer who has never paid before is new. A customer who stops paying churns, taking all of last month's MRR with them. A customer who pays more is expansion and one who pays less, but still pays, is contraction. Net new MRR is the sum of the parts, so the bridge always adds up to the change in MRR.

Customers who come back after a month or more at zero are counted as reactivation, on their own line. Folding them into new MRR would make acquisition look better than it is and hide that the same customer churned earlier. If you want the four-part bridge that StartupReader's ARR and MRR calculator uses, add reactivation to new.

  • The first month in the file is the opening balance, with no movements.
  • A customer who left before the file starts and returns inside it counts as new: the file has no earlier history.
  • A customer who changes billing ID shows up as one churn and one new customer, so Claude asks about merged or renamed accounts before running.

Which churn and retention definitions it uses

Every rate uses the start of the month as its base, as StartupReader's churn rate calculator does: customers lost divided by customers at the start, and MRR lost to cancellations and downgrades divided by starting MRR. Customers who joined during the month are left out of both, which is why churn measured on an average customer count, a convention some dashboards use, comes out lower in a growing company.

Net revenue retention is starting MRR plus expansion, minus contraction and churn, divided by starting MRR: what the customers you already had pay now. Gross revenue retention is the same without expansion, so it can never pass 100%. New and reactivated customers are outside both. The script prints these definitions under its results, so the analysis can state them and next month's figures can be compared like with like.

Investors usually quote NRR over 12 months, for the customers who were paying a year ago. That needs 13 months of data, so with a shorter file the script labels its cohort NRR with the number of months it covers, and the skill tells Claude not to call it annual.

From the bridge to unit economics

LTV is ARPA times gross margin divided by monthly churn, the formula behind StartupReader's LTV and CAC calculator. The skill uses gross profit, not revenue, and by default the average monthly logo churn over the file rather than one month's figure, which with a few dozen customers can swing by several points when one account leaves.

CAC is sales and marketing spend divided by the customers won over the same months, and Claude asks for all of the spend: salaries, tools and agencies, not only ads. Burn multiple is measured over the last three months, because a single month swings with the timing of deals. Runway is cash divided by net burn with burn held flat; the burn rate and runway calculator shows what revenue and cost growth would do to it.

Worked example

Acme Desk, a made-up helpdesk software startup, exports 12 months of MRR by customer: 32 customers from November 2025 to October 2026. It spent $6,000 on sales and marketing over the last three months, runs at an 80% gross margin, has $90,000 in the bank and burns about $6,000 a month.

The founder asks

Here's our MRR export (attached). How are churn and retention looking, and do our unit economics work? $6k of sales and marketing over the last three months, 80% gross margin, $90k in the bank, burning about $6k a month.

Claude reads the skill, then runs its script with the figures from the documents. These are the script's real outputs:

$ python scripts/saas_metrics.py assets/example-mrr.csv --last 4 --spend 6000 --spend-months 3 --gross-margin 0.8 --cash 90000 --net-burn 6000
SaaS metrics: 32 customers, 12 months (2025-11 to 2026-10). Amounts in the file's currency.Opening month 2026-06: MRR 4,526, 24 customersMRR bridge                2026-07  2026-08  2026-09  2026-10Starting MRR                4,526    4,725    4,875    5,025+ New                          99      299       99      248+ Reactivation                  0        0        0        0+ Expansion                   100        0      150        0- Contraction                   0      100        0        0- Churned                       0       49       99      299= Net new MRR                 199      150      150      -51Ending MRR                  4,725    4,875    5,025    4,974MRR growth                   4.4%     3.2%     3.1%    -1.0%ARR (MRR x 12)             56,700   58,500   60,300   59,688Customers (end)                25       25       25       26  new / back / lost         1/0/0    1/0/1    1/0/1    2/0/1Logo churn                   0.0%     4.0%     4.0%     4.0%Gross revenue churn          0.0%     3.2%     2.0%     6.0%Net revenue retention      102.2%    96.8%   101.0%    94.0%Gross revenue retention    100.0%    96.8%    98.0%    94.0%ARPA                          189      195      201      191 Over the last 11 months (a 12-month figure needs 13 months of data), 2025-11 to 2026-10  MRR 3,182 to 4,974: 56.3% (4.1% a month, compounded)  Average monthly logo churn 2.86%, about 29.4% a year compounded  Customers paying in 2025-11 (18): NRR 90.7%, GRR 71.8% Unit economics (2026-10)  CAC = 6,000 spend / 4 customers (new in 2026-08 to 2026-10) = 1,500  Monthly gross profit per customer = ARPA 191 x 0.8 = 153  LTV = 153 / 2.86% monthly churn (average monthly logo churn) = 5,353  LTV:CAC = 3.6:1  CAC payback = 1,500 / 153 = 9.8 months Cash and efficiency  Runway = 90,000 cash / 6,000 net burn = 15.0 months (burn held flat)  Burn multiple (3 months) = 18,000 net burn / 2,988 net new ARR = 6.0x Conventions (StartupReader's ARR and MRR and churn rate calculators): rates use the start of the month.  Logo churn = customers lost / customers at the start. Gross revenue churn = (contraction + churned) / starting MRR.  NRR = (starting MRR + expansion - contraction - churned) / starting MRR; GRR = the same without expansion.  Reactivation = a customer paying again after a month or more at zero; like new MRR, it is outside NRR and GRR.

The answer, in the skill's format (abridged):

SaaS metrics: Acme Desk, November 2025 to October 2026

The headline

MRR grew from $3,182 to $4,974 over 11 months (56.3%, or 4.1% a month compounded), and run-rate ARR is $59,688. The growth comes from new customers: the 18 customers paying in November 2025 now pay 90.7% of what they did then. October was a down month, because $299 of churned MRR outweighed $248 of new MRR.

MRR bridge

Jul 2026 Aug 2026 Sep 2026 Oct 2026
Starting MRR $4,526 $4,725 $4,875 $5,025
+ New $99 $299 $99 $248
+ Expansion $100 $0 $150 $0
- Contraction $0 $100 $0 $0
- Churned $0 $49 $99 $299
Ending MRR $4,725 $4,875 $5,025 $4,974

New sales held up; churned MRR rose every month, from $0 in July to $299 in October.

Retention and churn

Aug 2026 Sep 2026 Oct 2026
Logo churn 4.0% 4.0% 4.0%
Gross revenue churn 3.2% 2.0% 6.0%
NRR 96.8% 101.0% 94.0%
GRR 96.8% 98.0% 94.0%

Average monthly logo churn is 2.86%, about 29.4% a year compounded. With 25 customers, each one who leaves moves logo churn by 4 points, so watch the trend rather than any one month. Over 11 months (not yet a 12-month figure), NRR is 90.7% and GRR 71.8%.

Unit economics and efficiency

  • CAC is $1,500: $6,000 over the 4 customers won from August to October.
  • LTV is $5,353 and LTV:CAC 3.6:1, above the common 3:1 rule of thumb. It rests on 11 months of churn data.
  • CAC payback is 9.8 months, inside the 12 to 18 months commonly aimed for.
  • Runway is 15.0 months at $6,000 a month, with burn held flat.
  • Burn multiple is 6.0x over three months, above the 3x that the commonly quoted bands call bad. On under $60,000 of ARR one deal can swing it, so track it each quarter.

What to look at next

  1. October's churned account: find out why it left before it becomes a pattern.
  2. Expansion: NRR was above 100% only in July and September, the two months with expansion MRR.

Definitions used

Rates use the start of each month. NRR = (starting MRR + expansion - contraction - churned) / starting MRR; GRR is the same without expansion.

A made-up company and numbers. Your answer depends on your documents, and Claude's wording varies from run to run; the figures come from the script.

Install it

The skill is one folder. Download it once and add it wherever you use Claude; skills don't sync between claude.ai, Claude Code and the API, so each needs its own copy.

claude.ai and the Claude apps

  1. Download the ZIP and keep it zipped.
  2. In Claude, open Settings > Capabilities and turn on Code execution and file creation (called Create and edit files in the newer settings layout).
  3. Open Customize > Skills, click +, choose Create skill, then Upload a skill, and pick the ZIP.
  4. Leave the skill switched on. Claude uses it when your request matches its description; you don't have to name it.

On Team and Enterprise plans an owner first turns on code execution and skills under Organization settings > Plugins & skills. Anthropic's help centre has the details for each plan.

Claude Code

Unzip it into your personal skills folder to use it in every project, or into .claude/skills/ in a repository to share it with your team. Claude picks it up automatically; you can also run it by typing /saas-metrics-analysis.

Terminal
mkdir -p ~/.claude/skills
unzip saas-metrics-analysis.zip -d ~/.claude/skills/

Skills with a script need Python 3 on your computer.

Claude API

Upload the unzipped folder once; the response includes the skill's id. Uploaded skills are shared across your API workspace.

Terminal
curl -X POST "https://api.anthropic.com/v1/skills" \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2026-10-21" \
  -F "files[]=@saas-metrics-analysis/SKILL.md;filename=saas-metrics-analysis/SKILL.md" \
  -F "files[]=@saas-metrics-analysis/assets/example-mrr.csv;filename=saas-metrics-analysis/assets/example-mrr.csv" \
  -F "files[]=@saas-metrics-analysis/references/definitions.md;filename=saas-metrics-analysis/references/definitions.md" \
  -F "files[]=@saas-metrics-analysis/scripts/saas_metrics.py;filename=saas-metrics-analysis/scripts/saas_metrics.py"

Then list it in a request's container, with the code execution tool that skills run in (up to 20 skills per request):

Terminal
curl https://api.anthropic.com/v1/messages \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2026-10-21" \
  -H "content-type: application/json" \
  -d '{
    "model": "claude-opus-5-5",
    "max_tokens": 4096,
    "container": {
      "skills": [{ "type": "custom", "skill_id": "YOUR_SKILL_ID", "version": "latest" }]
    },
    "tools": [{ "type": "code_execution_20250825", "name": "code_execution" }],
    "messages": [{ "role": "user", "content": "..." }]
  }'

From Anthropic's skills API guide. Skills on the API run with no network access, which is why these skills' scripts use only Python's standard library.

What's in the skill

Every file, exactly as it is in the download. Claude reads SKILL.md when a request matches, opens the other files only when a step needs them, and runs scripts without loading their code. Read them before you install: a skill can direct Claude to run code, so only add skills you trust.

Folder
saas-metrics-analysis/
  SKILL.md
  assets/example-mrr.csv
  references/definitions.md
  scripts/saas_metrics.py
SKILL.md119 lines
---
name: saas-metrics-analysis
description: Computes SaaS metrics from a CSV of monthly recurring revenue by customer (an export from Stripe, Chargebee, another billing tool or a spreadsheet). Builds the monthly MRR bridge (new, expansion, contraction, churned and reactivation MRR) and reports ending MRR, ARR, customer count, logo churn, gross revenue churn, net revenue retention (NRR), gross revenue retention (GRR) and ARPA, then CAC, LTV, LTV:CAC, CAC payback, runway, burn multiple and the Rule of 40 when spend, gross margin and cash figures are given. A bundled script does the arithmetic and prints the definitions it uses. Use when a founder shares MRR, subscription or billing data, or asks about MRR growth, ARR, churn rate, customer churn, revenue churn, net dollar retention, expansion revenue, unit economics, LTV to CAC ratio, CAC payback, burn multiple, runway or the Rule of 40, or needs metrics for a board meeting, investor update or fundraise.
license: MIT
compatibility: Works on claude.ai, Claude Code and the Claude API. The script needs Python 3.8+ (standard library only) and no network access.
metadata:
  author: StartupReader
  version: "1.0"
  homepage: https://startupreader.com/claude-skills/saas-metrics-analysis
---

# SaaS metrics analysis

Turn a customer-by-month MRR export into the numbers a founder reports and investors check: where MRR came from, what leaked, and whether growth pays for itself. The script does all the arithmetic; your job is clean input, the right questions and a clear reading of the results.

## Workflow

Copy this checklist and tick it off as you go:

```
- [ ] 1. Get the data into one row per customer per month
- [ ] 2. Run scripts/saas_metrics.py on it
- [ ] 3. Ask for spend, gross margin and cash figures, then run again with them
- [ ] 4. Find what drove the months that stand out (--detail)
- [ ] 5. Write the analysis in the output format below
- [ ] 6. Check every figure against the script output
```

### 1. Prepare the data

The script reads one row per customer per month with columns for customer, month and MRR, or a wide sheet with one column per month. Common header names are matched automatically; for anything else pass `--customer-col`, `--month-col` and `--mrr-col` with the exact header. Months can be `2026-01`, `2026-01-31`, `Jan 2026` or `Jan-26`.

If the export is invoices, payments or subscriptions rather than MRR per month, build that table first and save it as a CSV. Normalise annual plans to monthly, and leave out setup fees, services, trials and taxes: the rules are in [references/definitions.md](references/definitions.md). Tell the founder what you changed.

Before running, ask if anything is unclear:
- Is the amount MRR, or invoiced amounts that include annual prepayments?
- Is there more than one currency?
- Has any customer changed ID (a merger or new billing account)? That would show as one churn and one new customer.

### 2. Run the numbers

```bash
python scripts/saas_metrics.py mrr.csv
python scripts/saas_metrics.py export.csv --customer-col "Account name" --month-col "Invoice month" --mrr-col "MRR (USD)"
python scripts/saas_metrics.py assets/example-mrr.csv --last 6
```

`--last N` shows only the last N months in the table; the summaries still use the whole file (up to 12 months). If the script exits with an error, fix the input it names and run it again. Never estimate a figure instead.

### 3. Add unit economics and cash

Ask for whichever of these the founder has, then rerun with them:

| Input | Flag | Note |
|---|---|---|
| Sales and marketing spend | `--spend 6000 --spend-months 3` | All of it: salaries, tools, agencies, not only ads. Three months is steadier than one. |
| New customers won | `--new-customers 4` | Only if the file's count is wrong, e.g. free sign-ups converted later |
| Gross margin | `--gross-margin 0.8` | A fraction. Hosting, third-party APIs, payment fees and support come out of it |
| Cash and net burn | `--cash 90000 --net-burn 6000` | Net burn is the average monthly cash loss, excluding money raised |
| Growth and profit margin | `--growth 0.6 --margin -0.25` | Rule of 40 only, for scaled companies. EBITDA or free cash flow margin: ask which |

```bash
python scripts/saas_metrics.py assets/example-mrr.csv --last 4 --spend 6000 --spend-months 3 --gross-margin 0.8 --cash 90000 --net-burn 6000
```

### 4. Find the drivers

For each month where NRR drops, churn spikes or growth stalls, list who moved:

```bash
python scripts/saas_metrics.py mrr.csv --detail 2026-10
```

Name customers by the IDs in the file. One large account often explains a whole month.

### 5. Output format

```markdown
# SaaS metrics: [Company], [first month] to [last month]

## The headline
Two or three sentences: MRR and ARR now and how fast they grew, where the growth came from, and the biggest leak.

## MRR bridge
The last three to six months from the script's table: starting MRR, new, reactivation, expansion, contraction, churned, ending MRR. One sentence on the trend.

## Retention and churn
| | [month] | [month] | [month] |
|---|---|---|---|
| Logo churn | | | |
| Gross revenue churn | | | |
| NRR | | | |
| GRR | | | |
Then the cohort NRR and GRR over the period, the compounded annual churn, and which customers drove the bad months.

## Unit economics and efficiency
CAC, LTV, LTV:CAC, CAC payback, runway, burn multiple (and Rule of 40 if relevant), each with the rule of thumb from references/definitions.md and one line on what it means here.

## What to look at next
Up to three specific checks or actions, most important first.

## Definitions used
The conventions block from the script output, plus any data changes you made in step 1.
```

### 6. Check before sending

- Every number in the analysis appears in the script output or the founder's own inputs.
- With fewer than 13 months of data, the cohort NRR is not called "12-month" or "annual".
- Churn and retention say which base they use (customers or MRR at the start of the month).
- Rules of thumb are quoted as rules of thumb, from references/definitions.md only, with no invented benchmarks.

## Rules

- Keep the script's definitions. If the founder's dashboard uses another convention (churn on average customers, NRR without contraction), show both, labelled, and say why they differ. The common differences are listed in [references/definitions.md](references/definitions.md).
- With a small base, one customer moves churn by several points. Say so when a rate rests on one or two customers, and prefer the period averages to any single month.
- ARR here is run-rate ARR (MRR x 12). Do not call it revenue or contracted ARR.
- LTV from a few months of churn data is fragile. Show it, but say how many months it rests on, and suggest a cohort table once there is a year of data.
assets/example-mrr.csv270 lines
customer_id,month,mrr
C01,2025-11,499
C02,2025-11,199
C03,2025-11,99
C04,2025-11,99
C05,2025-11,199
C06,2025-11,49
C07,2025-11,499
C08,2025-11,99
C09,2025-11,199
C10,2025-11,49
C11,2025-11,99
C12,2025-11,299
C13,2025-11,99
C14,2025-11,49
C15,2025-11,199
C16,2025-11,99
C17,2025-11,49
C18,2025-11,299
C01,2025-12,499
C02,2025-12,199
C03,2025-12,99
C04,2025-12,99
C05,2025-12,199
C06,2025-12,49
C07,2025-12,499
C08,2025-12,99
C09,2025-12,199
C10,2025-12,49
C12,2025-12,299
C13,2025-12,99
C14,2025-12,49
C15,2025-12,199
C16,2025-12,99
C17,2025-12,49
C18,2025-12,299
C19,2025-12,99
C01,2026-01,499
C02,2026-01,199
C03,2026-01,199
C04,2026-01,99
C05,2026-01,199
C06,2026-01,49
C07,2026-01,499
C08,2026-01,99
C09,2026-01,199
C10,2026-01,49
C12,2026-01,299
C13,2026-01,99
C14,2026-01,49
C15,2026-01,199
C16,2026-01,99
C17,2026-01,49
C18,2026-01,299
C19,2026-01,99
C20,2026-01,199
C21,2026-01,49
C01,2026-02,499
C02,2026-02,199
C03,2026-02,199
C04,2026-02,99
C06,2026-02,49
C07,2026-02,499
C08,2026-02,99
C09,2026-02,199
C10,2026-02,99
C12,2026-02,299
C13,2026-02,99
C14,2026-02,49
C15,2026-02,199
C16,2026-02,99
C17,2026-02,49
C18,2026-02,299
C19,2026-02,99
C20,2026-02,199
C21,2026-02,49
C22,2026-02,99
C01,2026-03,699
C02,2026-03,199
C03,2026-03,199
C04,2026-03,99
C06,2026-03,49
C07,2026-03,499
C08,2026-03,99
C09,2026-03,199
C10,2026-03,99
C12,2026-03,299
C13,2026-03,99
C14,2026-03,49
C15,2026-03,199
C16,2026-03,99
C17,2026-03,49
C18,2026-03,299
C19,2026-03,99
C20,2026-03,199
C21,2026-03,49
C22,2026-03,99
C23,2026-03,499
C32,2026-03,99
C01,2026-04,699
C02,2026-04,199
C03,2026-04,199
C04,2026-04,99
C07,2026-04,499
C08,2026-04,99
C09,2026-04,199
C10,2026-04,99
C12,2026-04,299
C13,2026-04,99
C14,2026-04,49
C15,2026-04,199
C16,2026-04,99
C17,2026-04,49
C18,2026-04,299
C19,2026-04,99
C20,2026-04,199
C21,2026-04,49
C22,2026-04,99
C23,2026-04,499
C24,2026-04,99
C32,2026-04,99
C01,2026-05,699
C02,2026-05,199
C03,2026-05,199
C04,2026-05,99
C07,2026-05,299
C08,2026-05,99
C09,2026-05,199
C10,2026-05,99
C12,2026-05,299
C13,2026-05,99
C14,2026-05,49
C15,2026-05,199
C16,2026-05,99
C17,2026-05,49
C18,2026-05,399
C19,2026-05,99
C20,2026-05,199
C21,2026-05,49
C22,2026-05,99
C23,2026-05,499
C24,2026-05,99
C25,2026-05,199
C32,2026-05,99
C01,2026-06,699
C02,2026-06,199
C03,2026-06,199
C04,2026-06,99
C05,2026-06,99
C07,2026-06,299
C08,2026-06,99
C09,2026-06,199
C10,2026-06,99
C12,2026-06,299
C13,2026-06,99
C14,2026-06,49
C15,2026-06,199
C16,2026-06,99
C17,2026-06,49
C18,2026-06,399
C19,2026-06,99
C20,2026-06,199
C22,2026-06,99
C23,2026-06,499
C24,2026-06,99
C25,2026-06,199
C26,2026-06,49
C32,2026-06,99
C01,2026-07,699
C02,2026-07,199
C03,2026-07,299
C04,2026-07,99
C05,2026-07,99
C07,2026-07,299
C08,2026-07,99
C09,2026-07,199
C10,2026-07,99
C12,2026-07,299
C13,2026-07,99
C14,2026-07,49
C15,2026-07,199
C16,2026-07,99
C17,2026-07,49
C18,2026-07,399
C19,2026-07,99
C20,2026-07,199
C22,2026-07,99
C23,2026-07,499
C24,2026-07,99
C25,2026-07,199
C26,2026-07,49
C27,2026-07,99
C32,2026-07,99
C01,2026-08,699
C02,2026-08,199
C03,2026-08,299
C04,2026-08,99
C05,2026-08,99
C07,2026-08,299
C08,2026-08,99
C09,2026-08,199
C10,2026-08,99
C12,2026-08,299
C13,2026-08,99
C15,2026-08,99
C16,2026-08,99
C17,2026-08,49
C18,2026-08,399
C19,2026-08,99
C20,2026-08,199
C22,2026-08,99
C23,2026-08,499
C24,2026-08,99
C25,2026-08,199
C26,2026-08,49
C27,2026-08,99
C28,2026-08,299
C32,2026-08,99
C01,2026-09,699
C02,2026-09,199
C03,2026-09,299
C04,2026-09,99
C05,2026-09,99
C07,2026-09,299
C08,2026-09,99
C09,2026-09,249
C10,2026-09,99
C12,2026-09,299
C13,2026-09,99
C15,2026-09,99
C16,2026-09,99
C17,2026-09,49
C18,2026-09,399
C19,2026-09,99
C20,2026-09,199
C22,2026-09,99
C23,2026-09,499
C24,2026-09,99
C25,2026-09,199
C26,2026-09,49
C27,2026-09,199
C28,2026-09,299
C29,2026-09,99
C01,2026-10,699
C02,2026-10,199
C03,2026-10,299
C04,2026-10,99
C05,2026-10,99
C08,2026-10,99
C09,2026-10,249
C10,2026-10,99
C12,2026-10,299
C13,2026-10,99
C15,2026-10,99
C16,2026-10,99
C17,2026-10,49
C18,2026-10,399
C19,2026-10,99
C20,2026-10,199
C22,2026-10,99
C23,2026-10,499
C24,2026-10,99
C25,2026-10,199
C26,2026-10,49
C27,2026-10,199
C28,2026-10,299
C29,2026-10,99
C30,2026-10,199
C31,2026-10,49
references/definitions.md92 lines
# SaaS metric definitions and rules of thumb

## Contents
- Preparing the data: what counts as MRR
- Monthly classification
- Formulas the script uses
- Conventions that differ elsewhere
- Rules of thumb (and how to quote them)
- Mistakes that flatter the numbers

## Preparing the data: what counts as MRR

MRR is recurring subscription revenue normalised to one month, after discounts. Build one row per customer per month before running the script.

- Annual plan: divide by 12 and count it in every month it covers, not in the month it is invoiced. Quarterly: divide by 3.
- Include subscription and licence fees on active contracts and recurring add-ons (extra seats, modules).
- Exclude one-time setup, implementation and onboarding fees; consulting and custom work; hardware; free trials and unpaid pilots; VAT, GST and sales tax.
- Usage-based fees: many companies count a committed minimum and report variable usage separately. Pick one treatment and say which.
- One currency per file. Convert at a stated rate before running.
- A customer who changes ID (merger, new billing account, renamed workspace) shows up as one churn and one new customer. Merge the IDs first if the founder confirms it is the same customer.
- Revenue and ARR are different: revenue is what was recognised over a period, including one-off income; ARR is a run rate at a point in time.

## Monthly classification

For each customer, this month's MRR is compared with last month's:

| Last month | This month | Movement |
|---|---|---|
| 0, never paid before | > 0 | New |
| 0, paid in an earlier month | > 0 | Reactivation |
| > 0 | 0 | Churned (all of last month's MRR) |
| > 0 | higher | Expansion (the increase) |
| > 0 | lower, above 0 | Contraction (the decrease) |

A customer who churned before the file starts and comes back inside it is counted as new, because the file has no earlier history. The first month in the file is the opening balance: it has no movements.

## Formulas the script uses

These match StartupReader's calculators and explainers (ARR and MRR, churn rate, LTV and CAC, burn rate and runway; arr-vs-mrr, unit-economics-ltv-cac, burn-multiple, rule-of-40).

- Net new MRR = new + reactivation + expansion - contraction - churned. The site's bridge has four parts (new, expansion, contraction, churned); reactivation is shown on its own line so returning customers are not mistaken for new ones. Add it to new to get the four-part version.
- MRR growth = net new MRR / starting MRR.
- ARR = ending MRR x 12 (run-rate ARR). Contracted ARR, which adds signed contracts not yet live, is a different number.
- Logo (customer) churn = customers lost / customers at the start of the month. Customers who joined during the month are not in the denominator.
- Annual churn, compounded = 1 - (1 - monthly churn)^12. 2% a month is about 21.5% a year, not 24%.
- Gross revenue churn = (contraction + churned MRR) / starting MRR.
- Net revenue retention (NRR) = (starting MRR + expansion - contraction - churned) / starting MRR. New and reactivated customers are excluded.
- Gross revenue retention (GRR) = (starting MRR - contraction - churned) / starting MRR = 100% - gross revenue churn. It cannot exceed 100%.
- Cohort NRR over a period = what customers paying in the base month pay now / what they paid then. With 13 months of data this is the 12-month NRR investors usually quote. Cohort GRR caps each customer at their base-month MRR.
- ARPA (average revenue per account) = ending MRR / customers at the end of the month.
- CAC = sales and marketing spend / new customers won in the same period.
- LTV = ARPA x gross margin / monthly churn. The script uses the average monthly logo churn over the window unless `--churn` is given.
- LTV:CAC = LTV / CAC.
- CAC payback (months) = CAC / (ARPA x gross margin).
- Net burn = monthly expenses - monthly revenue (the cash lost, leaving out money raised).
- Runway (months) = cash / net burn, with burn held flat. StartupReader's burn rate and runway calculator also models revenue and cost growth.
- Burn multiple = net burn / net new ARR over the same period. The script uses the last 3 months: (net burn x 3) / ((ending MRR now - ending MRR 3 months ago) x 12).
- Rule of 40 score = revenue or ARR growth over 12 months (%) + profit margin (%), with EBITDA or free cash flow margin. 40 or more passes.

## Conventions that differ elsewhere

Billing tools and dashboards make different choices. When the founder's own numbers disagree with the script, check these before assuming an error, and label which convention each figure uses.

- Churn on average customers ((start + end) / 2) instead of starting customers: gives a lower rate in a growing company. The site uses starting customers.
- Gross revenue churn without contraction (cancellations only): the site counts downgrades too.
- Reactivations folded into new MRR, or netted against churn.
- NRR over 12 months on a cohort, or a single month's NRR: say which. A monthly NRR of 99% is not the same as a 12-month NRR of 99%.
- LTV on revenue instead of gross profit: overstates LTV. The site uses gross profit.
- ARPA on average customers or on customers at the start.

## Rules of thumb (and how to quote them)

Quote these as rules of thumb, never as standards, and only these. They come from StartupReader's explainers and calculators. Do not add percentile benchmarks or attribute figures to named firms.

- LTV:CAC: at least 3:1 is the common rule of thumb for subscription businesses. Below 1:1, each customer loses money. Well above 5:1 can mean the company could invest more in growth.
- CAC payback: within about 12 to 18 months is the common aim. Often under 12 months for small-business software and up to 18 to 24 months for enterprise contracts with low churn.
- NRR: above 100% means existing customers grow on their own, before any new sales. What is strong varies a lot by market and customer size.
- Churn: there is no single good rate. Companies selling to small businesses usually see higher monthly churn than those selling to large enterprises on annual contracts. Compare with similar products and customers.
- Burn multiple: the bands most often quoted come from a framework popularised by investor David Sacks in 2020: under 1x amazing, 1x to 1.5x great, 1.5x to 2x good, 2x to 3x suspect, over 3x bad. Earlier-stage companies usually run higher multiples, and on a small ARR base one deal can swing it. Use a quarter or 12 months, not one month.
- Runway: raising usually takes three to six months, so founders aim to start a round with at least 9 to 12 months of runway and to raise enough for 18 to 24 months. Under 6 months, the company negotiates from weakness.
- Rule of 40: meant for scaled software companies. At seed and Series A, growth from a small base swings too much for the score to mean much; retention and burn multiple are better guides.

## Mistakes that flatter the numbers

- Booking an annual plan as MRR in the month it is paid.
- Counting trials, setup fees or services as recurring revenue.
- Using revenue instead of gross profit in LTV.
- Leaving salaries, tools and agency fees out of sales and marketing spend.
- Extrapolating churn from a few good months: early customers are often the keenest.
- Reporting only net new MRR, which hides rising churn behind strong new sales.
- Blending everything into one average. Compare cohorts (customers grouped by the month they started): if newer cohorts churn faster, the average hides it.
scripts/saas_metrics.py519 lines
#!/usr/bin/env python3
"""SaaS metrics from a customer-by-month MRR export.

Prints, for each month: the MRR bridge (new, reactivation, expansion,
contraction, churned), ending MRR, ARR, customers, logo churn, gross revenue
churn, net and gross revenue retention and ARPA. Optional inputs add CAC, LTV,
LTV:CAC, CAC payback, runway, burn multiple and the Rule of 40.

Standard library only and no network, so it runs in the Claude API sandbox, on
claude.ai and in Claude Code.

Usage:
  python scripts/saas_metrics.py FILE.csv [--last 6] [--detail 2026-10]
      [--customer-col NAME] [--month-col NAME] [--mrr-col NAME] [--delimiter ";"]
      [--spend 3000] [--spend-months 1] [--new-customers 2]
      [--gross-margin 0.8] [--churn 0.03]
      [--cash 150000] [--net-burn 9000] [--growth 0.6] [--margin -0.25]

Input (long format): one row per customer and month. Headers are matched
ignoring case, spaces and hyphens:
  customer  customer_id, customer, customer_name, account, account_id, client, client_id
  month     month, period, billing_month, date
  mrr       mrr, monthly_recurring_revenue, mrr_amount, recurring_revenue
Any other header: name it with --customer-col, --month-col or --mrr-col.
Wide format also works and is detected automatically: one row per customer,
one column per month (headers such as 2026-01, Jan 2026 or Jan-26).

Months: YYYY-MM, YYYY-MM-DD, YYYY/MM, MM/YYYY, Jan 2026, January 2026, Jan-26.
Full dates with the day first or last (10/01/2026) are refused as ambiguous.
A customer with no row, or a blank cell, in a month has zero MRR that month.
Several rows for one customer and month (several subscriptions) are added up.
Rows whose customer is "Total" or "Grand total" are skipped.
MRR must already be monthly and recurring: divide annual plans by 12 and leave
out one-off fees, services and taxes before running.

Rates are fractions: --gross-margin 0.8 means 80%, --margin -0.25 means -25%.
"""
import argparse
import csv
import io
import math
import re
import sys

ALIASES = {
    "customer": ["customer_id", "customer", "customer_name", "account", "account_id", "client", "client_id"],
    "month": ["month", "period", "billing_month", "date"],
    "mrr": ["mrr", "monthly_recurring_revenue", "mrr_amount", "recurring_revenue"],
}
FLAGS = {"customer": "--customer-col", "month": "--month-col", "mrr": "--mrr-col"}
MONTH_NAMES = ["january", "february", "march", "april", "may", "june", "july",
               "august", "september", "october", "november", "december"]
TOTAL_ROWS = {"total", "grand total", "totals", "sum"}
# Rule of 40: growth % + profit margin % should reach 40 (StartupReader's rule-of-40 explainer).
RULE_OF_40 = 40.0
# Burn multiple is measured over a quarter, as in StartupReader's burn-multiple explainer.
BURN_MULTIPLE_MONTHS = 3
MONTHS_PER_YEAR = 12  # ARR = MRR x 12; annual churn compounds 12 monthly rates
# A --growth or --margin fraction above this (1,000%) is almost certainly a whole-number percentage.
MAX_FRACTION = 10


def fail(message):
    print(f"Error: {message}", file=sys.stderr)
    sys.exit(2)


def money(x):
    if x is None:
        return "n/a"
    return f"{x:,.0f}"


def pct(x, places=1):
    return "n/a" if x is None else f"{x * 100:.{places}f}%"


def norm(header):
    return re.sub(r"[\s\-]+", "_", header.strip().lower())


def parse_month(text):
    """Return 'YYYY-MM' for a month or date string, or None if it is not one."""
    s = text.strip()
    m = re.match(r"^(\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?(?:[T ].*)?$", s)
    if m:
        year, month = int(m.group(1)), int(m.group(2))
    else:
        m = re.match(r"^(\d{1,2})[-/.](\d{4})$", s)
        if m:
            year, month = int(m.group(2)), int(m.group(1))
        else:
            m = re.match(r"^([A-Za-z]{3,9})\.?[\s,'\-]*(\d{4}|\d{2})$", s)
            if not m:
                return None
            name = m.group(1).lower()
            matches = [i for i, full in enumerate(MONTH_NAMES, 1) if full.startswith(name)]
            if len(matches) != 1:
                return None
            month = matches[0]
            year = int(m.group(2))
            if year < 100:
                year += 2000  # spreadsheet headers such as Jan-26 mean January 2026
    if not 1 <= month <= 12:
        return None
    return f"{year:04d}-{month:02d}"


def is_ambiguous_date(text):
    return re.match(r"^\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4}$", text.strip()) is not None


def parse_amount(text, where):
    s = text.strip()
    if s in ("", "-"):
        return 0.0
    s = re.sub(r"[\s,$₹€£]", "", s)
    if s.startswith("(") and s.endswith(")"):
        s = "-" + s[1:-1]
    try:
        value = float(s)
    except ValueError:
        fail(f"{where}: MRR '{text.strip()}' is not a number")
    if not math.isfinite(value):
        fail(f"{where}: MRR '{text.strip()}' is not a number")
    if value < 0:
        fail(f"{where}: MRR is negative ({text.strip()}). Credits and refunds are not MRR: "
             "net them into that customer's MRR for the month, or remove the row")
    return value


def month_range(first, last):
    y, m = int(first[:4]), int(first[5:])
    out = []
    while True:
        key = f"{y:04d}-{m:02d}"
        out.append(key)
        if key == last:
            return out
        m += 1
        if m == 13:
            y, m = y + 1, 1


def read_text(path):
    try:
        with open(path, "rb") as f:
            raw = f.read()
    except OSError as e:
        fail(f"cannot read {path}: {e.strerror}")
    for encoding in ("utf-8-sig", "cp1252"):
        try:
            return raw.decode(encoding)
        except UnicodeDecodeError:
            continue
    fail(f"{path} is not UTF-8 or Windows-1252 text; save it as UTF-8 CSV")


def find_column(kind, headers, given):
    normed = [norm(h) for h in headers]
    if given:
        if norm(given) in normed:
            return normed.index(norm(given))
        fail(f"{FLAGS[kind]} '{given}' is not a column. Found headers: {', '.join(headers)}")
    for alias in ALIASES[kind]:
        if alias in normed:
            return normed.index(alias)
    return None


def load(args):
    """Return ({(customer, month): mrr}, notes)."""
    text = read_text(args.file)
    lines = [ln for ln in text.splitlines() if ln.strip()]
    if not lines:
        fail(f"{args.file} is empty")
    delimiter = args.delimiter
    if not delimiter:
        delimiter = max([",", ";", "\t"], key=lines[0].count)
    rows = list(csv.reader(io.StringIO(text), delimiter=delimiter))
    rows = [(i + 1, r) for i, r in enumerate(rows) if any(cell.strip() for cell in r)]
    if len(rows) < 2:
        fail(f"{args.file} needs a header row and at least one data row")
    headers = [h.strip() for h in rows[0][1]]
    data = rows[1:]
    notes = []

    cust = find_column("customer", headers, args.customer_col)
    month_col = find_column("month", headers, args.month_col)
    mrr_col = find_column("mrr", headers, args.mrr_col)
    values = {}
    merged = 0
    skipped_totals = 0

    def add(customer, month, amount):
        nonlocal merged
        key = (customer, month)
        if key in values:
            merged += 1
        values[key] = values.get(key, 0.0) + amount

    month_headers = {i: parse_month(h) for i, h in enumerate(headers) if i != cust}
    month_headers = {i: k for i, k in month_headers.items() if k}
    wide = month_col is None and len(month_headers) >= 2 and not args.month_col

    if wide:
        if cust is None:
            if parse_month(headers[0]):
                fail(f"no customer column. Found headers: {', '.join(headers)}. Name it with --customer-col")
            cust = 0
        keys = list(month_headers.values())
        if len(set(keys)) != len(keys):
            fail("two month columns are the same month: " + ", ".join(sorted({k for k in keys if keys.count(k) > 1})))
        ignored = [h for i, h in enumerate(headers) if i != cust and i not in month_headers]
        notes.append(f"Read as a wide table: one column per month, customers in '{headers[cust]}'.")
        if ignored:
            notes.append("Ignored columns that are not months: " + ", ".join(ignored) + ".")
        for line, row in data:
            name = row[cust].strip() if cust < len(row) else ""
            if name.lower() in TOTAL_ROWS:
                skipped_totals += 1
                continue
            if not name:
                fail(f"row {line}: the customer is blank")
            for i, key in month_headers.items():
                cell = row[i] if i < len(row) else ""
                add(name, key, parse_amount(cell, f"row {line}, column '{headers[i]}'"))
    else:
        for kind, col in (("customer", cust), ("month", month_col), ("mrr", mrr_col)):
            if col is None:
                fail(f"no {kind} column. Found headers: {', '.join(headers)}. "
                     f"Name it with {FLAGS[kind]}, e.g. {FLAGS[kind]} \"{headers[0]}\"")
        for line, row in data:
            cells = row + [""] * (len(headers) - len(row))
            name = cells[cust].strip()
            if name.lower() in TOTAL_ROWS:
                skipped_totals += 1
                continue
            if not name:
                fail(f"row {line}: the customer is blank")
            raw_month = cells[month_col]
            key = parse_month(raw_month)
            if key is None:
                if is_ambiguous_date(raw_month):
                    fail(f"row {line}: '{raw_month.strip()}' could be day/month or month/day. "
                         "Convert the month column to YYYY-MM")
                fail(f"row {line}: cannot read the month '{raw_month.strip()}'. Use YYYY-MM, e.g. 2026-01")
            add(name, key, parse_amount(cells[mrr_col], f"row {line}"))

    if merged:
        notes.append(f"{merged} row(s) repeated a customer and month already seen; their MRR was added together.")
    if skipped_totals:
        notes.append(f"Skipped {skipped_totals} total row(s).")
    if not values:
        fail("no data rows")
    # Round sums so float noise from adding rows never looks like expansion or contraction.
    return {k: round(v, 6) for k, v in values.items()}, notes


def compute(values):
    months_present = sorted({m for _, m in values})
    months = month_range(months_present[0], months_present[-1])
    missing = [m for m in months if m not in months_present]
    if missing:
        fail("no rows at all for " + ", ".join(missing) + ". Months must be consecutive; if the company "
             "really had no revenue then, add a row with MRR 0 for that month")
    if len(months) < 2:
        fail(f"only one month ({months[0]}) in the file: the bridge needs at least two")
    customers = sorted({c for c, _ in values})

    def val(c, i):
        return values.get((c, months[i]), 0.0)

    rows = []
    for i in range(1, len(months)):
        r = {"month": months[i], "new": 0.0, "reactivation": 0.0, "expansion": 0.0, "contraction": 0.0,
             "churned": 0.0, "n_new": 0, "n_react": 0, "n_lost": 0, "n_start": 0, "n_end": 0,
             "movers": {"New": [], "Reactivation": [], "Expansion": [], "Contraction": [], "Churned": []}}
        r["start"] = sum(val(c, i - 1) for c in customers)
        r["end"] = sum(val(c, i) for c in customers)
        for c in customers:
            p, q = val(c, i - 1), val(c, i)
            r["n_start"] += p > 0
            r["n_end"] += q > 0
            if p == 0 and q > 0:
                if any(val(c, j) > 0 for j in range(i - 1)):
                    r["reactivation"] += q
                    r["n_react"] += 1
                    r["movers"]["Reactivation"].append((c, q))
                else:
                    r["new"] += q
                    r["n_new"] += 1
                    r["movers"]["New"].append((c, q))
            elif p > 0 and q == 0:
                r["churned"] += p
                r["n_lost"] += 1
                r["movers"]["Churned"].append((c, p))
            elif q > p:
                r["expansion"] += q - p
                r["movers"]["Expansion"].append((c, q - p))
            elif q < p:
                r["contraction"] += p - q
                r["movers"]["Contraction"].append((c, p - q))
        r["net_new"] = r["new"] + r["reactivation"] + r["expansion"] - r["contraction"] - r["churned"]
        assert abs(r["start"] + r["net_new"] - r["end"]) < 1e-6
        start = r["start"]
        kept = start - r["contraction"] - r["churned"]
        r["growth"] = r["net_new"] / start if start > 0 else None
        r["arr"] = r["end"] * MONTHS_PER_YEAR
        r["logo_churn"] = r["n_lost"] / r["n_start"] if r["n_start"] else None
        r["gross_revenue_churn"] = (r["contraction"] + r["churned"]) / start if start > 0 else None
        r["nrr"] = (kept + r["expansion"]) / start if start > 0 else None
        r["grr"] = kept / start if start > 0 else None
        r["arpa"] = r["end"] / r["n_end"] if r["n_end"] else None
        rows.append(r)
    return months, customers, val, rows


def print_table(rows, opening):
    lines = [
        ("Starting MRR", lambda r: money(r["start"])),
        ("+ New", lambda r: money(r["new"])),
        ("+ Reactivation", lambda r: money(r["reactivation"])),
        ("+ Expansion", lambda r: money(r["expansion"])),
        ("- Contraction", lambda r: money(r["contraction"])),
        ("- Churned", lambda r: money(r["churned"])),
        ("= Net new MRR", lambda r: money(r["net_new"])),
        ("Ending MRR", lambda r: money(r["end"])),
        ("MRR growth", lambda r: pct(r["growth"])),
        ("ARR (MRR x 12)", lambda r: money(r["arr"])),
        ("Customers (end)", lambda r: str(r["n_end"])),
        ("  new / back / lost", lambda r: f"{r['n_new']}/{r['n_react']}/{r['n_lost']}"),
        ("Logo churn", lambda r: pct(r["logo_churn"])),
        ("Gross revenue churn", lambda r: pct(r["gross_revenue_churn"])),
        ("Net revenue retention", lambda r: pct(r["nrr"])),
        ("Gross revenue retention", lambda r: pct(r["grr"])),
        ("ARPA", lambda r: money(r["arpa"])),
    ]
    cells = [[fn(r) for r in rows] for _, fn in lines]
    width = max(len(r["month"]) for r in rows)
    width = max([width] + [len(c) for row in cells for c in row]) + 2
    label = max(len(name) for name, _ in lines) + 1
    print(f"Opening month {opening}")
    print(f"{'MRR bridge':<{label}}" + "".join(f"{r['month']:>{width}}" for r in rows))
    for (name, _), row in zip(lines, cells):
        print(f"{name:<{label}}" + "".join(f"{c:>{width}}" for c in row))


def main():
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("file", help="CSV of MRR by customer and month")
    parser.add_argument("--customer-col", help="header of the customer column")
    parser.add_argument("--month-col", help="header of the month column")
    parser.add_argument("--mrr-col", help="header of the MRR column")
    parser.add_argument("--delimiter", help="field separator (default: guessed from the header: , ; or tab)")
    parser.add_argument("--last", type=int, help="show only the last N months in the table (summaries use the whole file)")
    parser.add_argument("--detail", metavar="YYYY-MM", help="list each customer's movement in this month")
    parser.add_argument("--spend", type=float, help="sales and marketing spend over the last --spend-months months")
    parser.add_argument("--spend-months", type=int, default=1, help="months the spend covers, ending with the latest (default 1)")
    parser.add_argument("--new-customers", type=int, help="customers won in those months (default: new customers in the file)")
    parser.add_argument("--gross-margin", type=float, help="gross margin as a fraction, e.g. 0.8")
    parser.add_argument("--churn", type=float, help="monthly churn for LTV as a fraction (default: average monthly logo churn)")
    parser.add_argument("--cash", type=float, help="cash in the bank now")
    parser.add_argument("--net-burn", type=float, help="average monthly net burn over recent months (expenses minus revenue)")
    parser.add_argument("--growth", type=float, help="revenue or ARR growth over the last 12 months as a fraction, for the Rule of 40")
    parser.add_argument("--margin", type=float, help="EBITDA or free cash flow margin as a fraction (negative for a loss), for the Rule of 40")
    args = parser.parse_args()
    try:
        sys.stdout.reconfigure(encoding="utf-8")  # customer names and headers may not be ASCII
    except (AttributeError, ValueError):
        pass

    for flag in ("gross_margin", "churn"):
        v = getattr(args, flag)
        if v is not None and not 0 < v <= 1:
            fail(f"--{flag.replace('_', '-')} must be a fraction between 0 and 1, e.g. 0.8 for 80%")
    for flag in ("spend", "cash"):
        v = getattr(args, flag)
        if v is not None and v < 0:
            fail(f"--{flag.replace('_', '-')} cannot be negative")
    if args.last is not None and args.last < 1:
        fail("--last must be at least 1")
    if args.spend_months < 1:
        fail("--spend-months must be at least 1")
    if args.new_customers is not None and args.new_customers < 0:
        fail("--new-customers cannot be negative")
    for flag in ("growth", "margin"):
        v = getattr(args, flag)
        if v is not None and abs(v) > MAX_FRACTION:
            fail(f"--{flag} looks like a percentage: give a fraction, e.g. 0.6 for 60%")

    values, notes = load(args)
    months, customers, val, rows = compute(values)
    last = rows[-1]
    detail = None
    if args.detail:
        key = parse_month(args.detail)
        detail = next((r for r in rows if r["month"] == key), None)
        if detail is None:
            fail(f"--detail {args.detail}: not a month with movements in the file ({months[1]} to {months[-1]})")

    print(f"SaaS metrics: {len(customers)} customers, {len(months)} months ({months[0]} to {months[-1]}). "
          "Amounts in the file's currency.")
    for note in notes:
        print(f"Note: {note}")
    shown = rows[-args.last:] if args.last else rows
    opening = months[months.index(shown[0]["month"]) - 1]
    n = shown[0]["n_start"]
    print_table(shown, f"{opening}: MRR {money(shown[0]['start'])}, {n} customer{'' if n == 1 else 's'}")

    # Over the last 12 months, or the whole file if shorter.
    window = min(MONTHS_PER_YEAR, len(rows))
    base = len(months) - 1 - window
    cohort = [c for c in customers if val(c, base) > 0]
    cohort_start = sum(val(c, base) for c in cohort)
    period = rows[-window:]
    churn_rates = [r["logo_churn"] for r in period if r["logo_churn"] is not None]
    avg_churn = sum(churn_rates) / len(churn_rates) if churn_rates else None
    label = "last 12 months" if window == MONTHS_PER_YEAR else f"last {window} months (a 12-month figure needs 13 months of data)"
    print()
    print(f"Over the {label}, {months[base]} to {months[-1]}")
    start_mrr, end_mrr = rows[-window]["start"], last["end"]
    if start_mrr > 0:
        cmgr = (end_mrr / start_mrr) ** (1 / window) - 1 if end_mrr > 0 else None
        print(f"  MRR {money(start_mrr)} to {money(end_mrr)}: {pct(end_mrr / start_mrr - 1)}"
              f" ({pct(cmgr)} a month, compounded)")
    if avg_churn is not None:
        print(f"  Average monthly logo churn {pct(avg_churn, 2)}, about {pct(1 - (1 - avg_churn) ** MONTHS_PER_YEAR)} a year compounded")
    if cohort_start > 0:
        now = sum(val(c, len(months) - 1) for c in cohort)
        kept = sum(min(val(c, len(months) - 1), val(c, base)) for c in cohort)
        print(f"  Customers paying in {months[base]} ({len(cohort)}): NRR {pct(now / cohort_start)}, GRR {pct(kept / cohort_start)}")

    if detail is not None:
        print()
        print(f"Customer movements in {detail['month']}")
        for kind, items in detail["movers"].items():
            items = sorted(items, key=lambda x: (-x[1], x[0]))
            print(f"  {kind:<13}" + (", ".join(f"{c} {money(v)}" for c, v in items) if items else "none"))

    # Unit economics: the formulas of StartupReader's LTV and CAC calculator.
    if args.spend is not None or args.gross_margin is not None:
        print()
        print(f"Unit economics ({last['month']})")
        cac = None
        if args.spend is not None:
            k = min(args.spend_months, len(rows))
            won = args.new_customers if args.new_customers is not None else sum(r["n_new"] for r in rows[-k:])
            window_label = rows[-k]["month"] + ("" if k == 1 else f" to {last['month']}")
            source = f"given, {window_label}" if args.new_customers is not None else f"new in {window_label}"
            if won > 0:
                cac = args.spend / won
                print(f"  CAC = {money(args.spend)} spend / {won} customers ({source}) = {money(cac)}")
            else:
                print(f"  CAC: not defined, no new customers ({source})")
        if args.gross_margin is not None and last["arpa"]:
            gp = last["arpa"] * args.gross_margin
            churn = args.churn if args.churn is not None else avg_churn
            churn_source = "given" if args.churn is not None else "average monthly logo churn"
            print(f"  Monthly gross profit per customer = ARPA {money(last['arpa'])} x {args.gross_margin:g} = {money(gp)}")
            ltv = None
            if churn:
                ltv = gp / churn
                print(f"  LTV = {money(gp)} / {pct(churn, 2)} monthly churn ({churn_source}) = {money(ltv)}")
            else:
                print("  LTV: not defined, no churn in the period (give --churn to estimate it)")
            if cac:
                if ltv:
                    print(f"  LTV:CAC = {ltv / cac:.1f}:1")
                print(f"  CAC payback = {money(cac)} / {money(gp)} = {cac / gp:.1f} months")
        elif args.gross_margin is None:
            print("  LTV and CAC payback need --gross-margin (LTV uses gross profit, not revenue)")

    # Cash: StartupReader's burn rate and runway calculator and burn-multiple explainer.
    if args.cash is not None or args.net_burn is not None:
        print()
        print("Cash and efficiency")
        if args.net_burn is not None and args.net_burn > 0:
            if args.cash is not None:
                print(f"  Runway = {money(args.cash)} cash / {money(args.net_burn)} net burn = "
                      f"{args.cash / args.net_burn:.1f} months (burn held flat)")
            k = min(BURN_MULTIPLE_MONTHS, len(rows))
            nn_arr = (last["end"] - rows[-k]["start"]) * MONTHS_PER_YEAR
            burn = args.net_burn * k
            if nn_arr > 0:
                print(f"  Burn multiple ({k} months) = {money(burn)} net burn / {money(nn_arr)} net new ARR = {burn / nn_arr:.1f}x")
            else:
                print(f"  Burn multiple: not meaningful, ARR did not grow over the last {k} months")
        elif args.net_burn is not None:
            print(f"  Not burning cash (net burn {money(args.net_burn)}): runway is not limited by burn")
        else:
            print("  Runway and burn multiple need --net-burn")

    if args.margin is not None:
        growth = args.growth
        source = "given"
        if growth is None and window == MONTHS_PER_YEAR and rows[-window]["start"] > 0:
            growth = last["end"] / rows[-window]["start"] - 1
            source = "ARR growth over the last 12 months"
        print()
        if growth is None:
            print("Rule of 40: needs --growth (12 months of growth), or 13 months of data")
        else:
            score = (growth + args.margin) * 100
            verdict = "passes" if score >= RULE_OF_40 else "falls short"
            sign = "-" if args.margin < 0 else "+"
            print(f"Rule of 40 = {growth * 100:.1f}% growth ({source}) {sign} {abs(args.margin) * 100:.1f}% margin"
                  f" = {score:.1f}: {verdict} (the bar is {RULE_OF_40:g})")

    print()
    print("Conventions (StartupReader's ARR and MRR and churn rate calculators): rates use the start of the month.")
    print("  Logo churn = customers lost / customers at the start. Gross revenue churn = (contraction + churned) / starting MRR.")
    print("  NRR = (starting MRR + expansion - contraction - churned) / starting MRR; GRR = the same without expansion.")
    print("  Reactivation = a customer paying again after a month or more at zero; like new MRR, it is outside NRR and GRR.")


if __name__ == "__main__":
    main()

Limits

  • It is only as good as the MRR in the file. When the export is invoices or subscriptions, Claude converts them first and says what it changed, but check annual plans, discounts and one-off fees yourself.
  • It works month by month in one currency. Convert mixed currencies first, and for a quarterly view use the quarter-end months and add up the movements.
  • LTV uses the simple formula (gross profit per customer divided by monthly churn). With under a year of data, or a few dozen customers, treat it as a rough guide and build a cohort table as data grows.
  • Cash, burn and spend figures come from you, not from your accounts, so the runway and CAC are only as accurate as those inputs.
  • Its benchmarks are the rules of thumb in StartupReader's explainers. It doesn't compare you with peer data, and good churn and retention vary a lot by market and customer size.

Questions

What export does it need?

Any CSV with a customer, a month and that customer's MRR for the month, one row each, or a spreadsheet with one row per customer and one column per month. Column names from billing tools and spreadsheets differ, so the script matches common ones and Claude names the rest with flags. If you only have invoices or a subscription list, Claude builds the monthly MRR table first.

Why doesn't its churn match my billing dashboard?

Usually a different convention, not an error. Dashboards may measure churn on the average customer count, leave downgrades out of revenue churn, or fold reactivations into new MRR. The skill uses StartupReader's definitions, prints them, and when your dashboard differs it shows both figures, labelled.

How does it treat annual plans?

MRR has to be monthly, so an annual plan counts as one twelfth of its price in every month it covers, not the full amount in the month it is invoiced. If your export has invoiced amounts, Claude converts them before running the script and tells you which rows it changed.

Is the NRR it reports a 12-month figure?

Only with 13 months of data. Monthly NRR is in the table for every month, and the summary gives NRR and GRR for the customers you had at the start of the file, labelled with the number of months it covers.

Does the skill send my revenue data anywhere?

The skill is instructions and a Python script with no network calls, so it sends nothing anywhere. The file you upload is handled under your Claude plan's normal data terms. You can also replace customer names with IDs before uploading: the script only needs them to be consistent.

Learn what the skill checks

The skill is built from the same rules and formulas as these StartupReader pages, so you can follow its reasoning step by step.

Pairs well with

The weekly brief

More on saas metrics analysis, once a week

Who raised, who launched and the founder terms worth knowing, in one short email a week.

The StartupReader app

Startup news in 60-second swipes

Funding, launches and founder programs, each on one card with the key numbers. Free, with read-aloud and auto-scroll.

Get it on Google PlayComing soon

Building something? List your startup free.

Written and tested by the StartupReader editorial desk with AI assistance; the example outputs are the skill's scripts run on the made-up figures shown. StartupReader is not affiliated with Anthropic. The skill gives general information, not legal, tax or financial advice. Spotted something wrong? Tell us.