Founders

Building a Cohort Retention Table in SQL

Antonio Evans8 min read

A SQL query editor window with a cohort retention table displayed as a heatmap.
Key points

Key points

  1. 1A cohort retention table assigns every customer to a signup month, then measures how many of those customers still generate billed revenue in each later month.
  2. 2Revenue-based retention is a percentage of the cohort's original book of business, so it stays auditable against invoices rather than depending on product analytics events.
  3. 3The query reduces to three CTEs: cohort assignment, monthly invoice aggregation, and a dense grid left-joined to the aggregates so empty months appear as zeros.
  4. 4Validating totals against your billing system's own reports before trusting any retention percentage is the step that separates a real metric from a dashboard toy.

Why billing data beats event data for retention

Most cohort tutorials count a user as retained if they logged in or fired an event. That definition drifts: a tracking bug, a consent banner, or a mobile SDK update silently changes retention without a single customer leaving. Billing data has a different property. An invoice exists because someone paid, and the ledger is reconciled monthly by people who care whether the number is right. The Holistics walkthrough builds cohorts from user activities and signup timestamps, which is a fine starting point for product engagement, but it is not something a CFO or an investor can audit the Holistics cohort guide.

Revenue retention is also the metric that feeds unit economics. Lifetime value is commonly written as ARPU times gross margin divided by churn, and that churn input should come from observed cohort behavior rather than a blended company average the LTV-CAC guide. The RaiseMoney guide makes the same point explicitly: review unit economics by cohort and channel before trusting a blended average, because mixing old and new customers hides which one is failing the RaiseMoney unit economics guide. A cohort table from billing data is the artifact that makes that instruction executable.

There is a second reason to build it in SQL rather than in a subscription analytics tool. The query is versioned in your repository, reviewed like any other code, and re-run identically by anyone with database access. When a board deck says the January cohort retained 84% of its revenue at month six, someone can open the .sql file and reproduce the number.

Model the data before you write a single query

The query is only as good as the tables under it. You need three things: a customer table with a reliable signup date, an invoice or charge table with one row per billed period, and a consistent way to identify the same customer across both.

The signup date problem. Your customers.created_at column may be account creation, not first payment. Plenty of trials never convert. If you cohort by account creation, some cohorts include people who never paid, which deflates retention for reasons that have nothing to do with product quality. The cleaner definition is the month of the customer's first successful charge. Derive it from billing rather than reading it off the customer row.

The invoice problem. Decide up front whether you are measuring gross revenue (invoiced) or net revenue (collected after refunds and credits). Both are defensible, but they are different metrics and you must not mix them. If you measure net, refunds and credit notes need to be subtracted in the same query, not in a separate report.

The identity problem. A customer who upgrades, downgrades, or reactivates may appear under several subscription records. Cohort identity must be stable at the customer level, not the subscription level, or one person can land in two cohorts.

A worked example. Suppose the January cohort was 40 customers who paid a combined 12,000 dollars in month zero. In month one they paid 10,800. In month two, 9,600. Revenue retention is 90% then 80% of the original base. That is a different question from "how many customers paid", which might read 36 and 33. Both views matter, and the grid below carries both.

DecisionOption AOption BWhich to pick
Cohort anchorAccount createdFirst paid invoiceFirst paid invoice, unless you sell a free product
MetricCustomer countRevenue amountBoth, in adjacent columns
Revenue basisGross invoicedNet collectedNet collected if refunds exceed 2% of revenue
PeriodCalendar monthCustomer anniversary monthCalendar month for reporting, anniversary for payback
Missing monthsNULLZeroZero in the grid, but flag months after data cutoff

Building the cohort assignment

Start with a CTE that gives every customer exactly one cohort month and one first payment date. Deriving the cohort from the first paid invoice, rather than joining a separate customers table, removes a whole class of mismatch bugs.

WITH first_payment AS (
    SELECT
        customer_id,
        MIN(paid_at) AS first_paid_at
    FROM invoices
    WHERE status = 'paid'
    GROUP BY customer_id
),
cohorts AS (
    SELECT
        customer_id,
        DATE_TRUNC('month', first_paid_at)::date AS cohort_month
    FROM first_payment
)

If you have customers who paid but have no row in a customers table, or customers in the customers table who never paid, this pattern silently drops them. That is usually what you want for revenue retention, but log the counts so you know how many records you excluded and why.

The StrataScratch guide on retention makes the case for cohorts in one sentence: newer customers churn more, so an aggregate retention rate mixes old and new and hides the fact that the product is failing new customers the StrataScratch retention guide. Cohorting by first payment is exactly the fix.

Aggregating revenue by month since signup

The second CTE joins invoices back to cohorts and computes how many months after the cohort month each invoice falls. Integer month arithmetic, rather than day counts divided by 30, keeps the buckets clean across short months.

monthly_revenue AS (
    SELECT
        c.cohort_month,
        i.customer_id,
        (DATE_PART('year', i.paid_at) - DATE_PART('year', c.cohort_month)) * 12
          + (DATE_PART('month', i.paid_at) - DATE_PART('month', c.cohort_month)) AS month_number,
        SUM(i.amount) AS revenue
    FROM invoices i
    JOIN cohorts c ON c.customer_id = i.customer_id
    WHERE i.status = 'paid'
    GROUP BY 1, 2, 3
)

Note the GROUP BY includes the customer. Without it you cannot count distinct paying customers in the same pass, and you would need a second scan of the invoice table. Grouping at (cohort, customer, month) lets the next layer roll up both revenue and customer counts.

One caveat on DATE_PART: in PostgreSQL it returns a double precision number, so cast to integer before using it as a column key. In MySQL the equivalent is TIMESTAMPDIFF(MONTH, cohort_month, paid_at), and the tutorial scripts in this cohort analysis video walk through that variant, including how they truncate dates so the month difference does not return null the cohort analysis SQL walkthrough.

Filling the grid so empty months read as zero

The classic mistake is to aggregate and pivot in one step. If a cohort had no revenue in month four, that cell simply does not exist, and a chart joins month three straight to month five. The fix is a generated grid of cohort months crossed with month numbers, left-joined to the aggregates. This is the same structure the Holistics query uses when it left-joins activity counts back to cohort sizes and divides to get a percentage the Holistics cohort guide.

SELECT
    g.cohort_month,
    g.month_number,
    COALESCE(r.revenue, 0) AS revenue,
    COALESCE(r.paying_customers, 0) AS paying_customers
FROM (SELECT DISTINCT cohort_month FROM cohorts) g
CROSS JOIN generate_series(0, 24) AS month_number
LEFT JOIN (
    SELECT cohort_month, month_number,
           SUM(revenue) AS revenue,
           COUNT(DISTINCT customer_id) AS paying_customers
    FROM monthly_revenue
    GROUP BY 1, 2
) r
  ON r.cohort_month = g.cohort_month
 AND r.month_number = g.month_number
ORDER BY g.cohort_month, g.month_number

generate_series is PostgreSQL specific. On a warehouse without it, a small numbers table with 25 rows works just as well and is portable. Cap the month numbers at a horizon you actually have data for: a cohort that is four months old should not show a 24-month row of zeros, because zeros read as churn. Add a months_elapsed column so consumers can filter out months that have not happened yet.

Adding the percentages and the pivot

Retention percentages come from dividing each cell by month zero for that cohort. Compute month zero in a separate CTE rather than in a window function over the whole grid, so the denominator is unambiguous.

SELECT
    g.cohort_month,
    g.month_number,
    g.revenue,
    g.revenue / NULLIF(base.base_revenue, 0) AS revenue_retention,
    g.paying_customers,
    g.paying_customers::numeric / NULLIF(base.base_customers, 0) AS logo_retention
FROM grid g
JOIN (
    SELECT cohort_month,
           SUM(CASE WHEN month_number = 0 THEN revenue END) AS base_revenue,
           SUM(CASE WHEN month_number = 0 THEN paying_customers END) AS base_customers
    FROM grid
    GROUP BY 1
) base USING (cohort_month)

NULLIF protects against divide-by-zero for any cohort whose month zero is empty. If that happens, it is a data problem, not a query problem, and you want the null to surface rather than a silent zero.

For presentation, most teams pivot months into columns. A FILTER clause with conditional aggregation does it without a dedicated pivot function:

SELECT
    cohort_month,
    MAX(revenue) FILTER (WHERE month_number = 0) AS m0,
    MAX(revenue) FILTER (WHERE month_number = 1) AS m1,
    MAX(revenue) FILTER (WHERE month_number = 2) AS m2
FROM grid
GROUP BY cohort_month
ORDER BY cohort_month

Wide tables are convenient in a BI tool and awkward at 24 columns. Keep the long format as the source of truth in your warehouse and let the presentation layer pivot it.

Which retention number do you actually report

Two metrics fall out of the same query and they answer different questions. Revenue retention tells you whether the money is holding up, and it can exceed 100% when a cohort expands. Logo retention tells you whether the people are holding up, and it never exceeds 100%.

The SaaS benchmarks make this distinction explicit: net revenue retention is calculated by adding expansion ARR and subtracting contraction and churned ARR from the starting base, and a number above 100% means existing customers are expanding faster than they shrink the SaaS Academy metrics guide. Gross revenue retention strips out expansion entirely and cannot exceed 100%, which makes it the honest measure of leakage. Klipfolio reports that the average annual net revenue retention rate sits between 85% and 135%, with more than half of the companies in the OpenView survey falling between 95% and 115% the Klipfolio SaaS benchmark list. Report both, side by side, and never let an expansion-heavy cohort hide a gross retention problem.

MetricFormula from the gridCan exceed 100%Best used for
Gross revenue retentionRevenue at month n divided by month 0, expansions excludedNoLeakage diagnosis
Net revenue retentionRevenue at month n including upsells, divided by month 0YesBoard reporting, LTV
Logo retentionPaying customers at month n divided by month 0NoProduct and onboarding
CAC paybackCumulative gross profit to month n versus acquisition costNot applicableCash planning

This is also where the cohort table pays for itself in cash planning. CAC payback is defined as acquisition cost divided by the gross margin dollars the customer generates, and it does not account for churn or the time value of money, which is precisely why measuring it on a real cohort, month by month, beats the one-customer formula the CAC payback guide. Practitioners who run the cohort version commonly find payback is longer than the simple formula suggested, because churned customers stop contributing before the cost is recovered the cohort payback walkthrough. Stripe's worked example, 50,000 dollars of spend acquiring 500 customers at 100 dollars of monthly revenue and 20 dollars of cost each, gives a 100 dollar CAC and a 1.25 month payback, which is the kind of number that only looks that good because churn is absent from the arithmetic the Stripe payback explainer.

If you would rather not hand-roll the reporting layer, a consulting call to scope the model before you build it is often cheaper than rebuilding it twice.

Validating the table against source systems

A retention table that disagrees with the billing system is worse than no table, because people will quote it. Run these checks every time the query changes.

First, reconcile revenue. Sum every cell in the grid across all cohorts and month numbers, and compare it to the total of paid invoices in the same window. Those numbers must match exactly. If they do not, you either excluded invoices (refunded, disputed, or with a status outside your filter) or double-counted customers with more than one record.

Second, check the month-zero diagonal. Month zero should equal the cohort's total first-month paid revenue, which you can pull directly from the billing system's cohort view for a single month. Start with the oldest cohort and the newest, since they are the two most likely to be affected by cutoff differences.

Third, spot-check individual customers. Pick five customers at random from one cohort and trace their invoice history by hand. Confirm they land in the right cohort month and that their revenue appears in the right month numbers. This catches timezone errors in paid_at, which are common when your warehouse stores UTC and your billing system bills on local calendar days. A payment at 23:30 on the last day of a month in one timezone is the first day of the next in another.

Fourth, check perpendicular totals. The sum of all cohort sizes should equal the count of distinct customers with at least one paid invoice. The sum of all paying customers in month one across all cohorts should equal the number of distinct customers who paid in month one after first paying in month zero. Both are quick and both catch join fan-out.

Finally, pin the query behind a test. A simple assertion that the grand total matches the invoice table total, run on a schedule, turns a one-time validation into a guardrail.

FAQ

FAQ

What counts as an active customer in a revenue-based cohort table?

A customer is active in a month if they have at least one paid invoice dated in that month. Counting only paid invoices, not issued ones, keeps the metric anchored to cash rather than to billing paperwork that may later be credited.

Should cohorts be built from signup date or first payment date?

Use first payment date unless you sell a free product where signup is a meaningful activation step. Cohorting on signup includes trials that never converted, which depresses retention for reasons unrelated to the product, and it makes the denominator hard to reconcile against billing.

Why does my cohort table show zeros for months that have not happened yet?

Because the generated grid covers a fixed horizon regardless of cohort age. Add a months-elapsed column and filter consumers to it, or generate the grid per cohort up to the current month, so a four-month-old cohort does not display 20 months of fake churn.

How do I handle refunds and credits?

Decide whether the metric is gross invoiced or net collected, then apply it consistently. For net collected, subtract refunds and credit notes in the same monthly aggregation, and reconcile the grid total against net revenue in the billing system rather than against gross invoiced amounts.

Can I get revenue retention above 100 percent?

Yes, when expansion from upsells and seat growth outpaces contraction and churn within a cohort. Report gross revenue retention alongside it, since that figure excludes expansion and shows the actual leakage, which is the number that tells you whether the underlying business is holding.

If the query work is straightforward but the surrounding pipeline is not, Quanta Inference offers one API key across text, image, and embedding models for whatever classification or enrichment step sits next to your warehouse.

Sources

Sources

  1. Quick Guide: Calculate Cohort Retention Analysis with SQL
  2. Unit Economics: The Complete Guide for Startup Founders - LTV-CAC Book
  3. Startup Unit Economics: CAC, LTV & Payback Guide - RaiseMoney
  4. Retention in SQL: How to Calculate User and Cohort Retention?
  5. Cohort Analysis Explained with SQL, Retention Made Simple!
  6. SaaS Metrics for Beginners: 10 KPIs Every Founder Should Track
  7. The top 12 SaaS metrics with benchmarks - Klipfolio
  8. How I Calculate the CAC Payback Period - The SaaS CFO
  9. How to Calculate CAC Payback Period for Subscription Businesses
  10. What is the CAC payback period? - Stripe
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles