FFastero

Connect any database. Ask in plain English.

Try free
Back to blog

Blog article

How to Build a Revenue Waterfall Chart in SQL

The MRR waterfall (or bridge chart) breaks monthly revenue into New, Expansion, Contraction, and Churn. Here's the complete SQL to compute it from raw subscription data, including the sanity check that catches wrong numbers before they reach a board deck.

Fastero Dev TeamFastero Dev Team
2026-08-05
sqlmrrsaas-metricswaterfallbillingtutorial
How to Build a Revenue Waterfall Chart in SQL

How to Build a Revenue Waterfall Chart in SQL

Total MRR is one number. It can go up, it can go down, and when it goes down nobody in the room can explain why without pulling up a spreadsheet. The revenue waterfall fixes that. It decomposes MRR change into the four movements that actually matter: New, Expansion, Contraction, Churn. Add an optional fifth — Reactivation — and you've got a complete picture of where your revenue is coming from and where it's leaking.

This is one of the harder SaaS SQL queries to get right. Most published versions have subtle bugs around customers who change plans multiple times in a month, or around the churned-and-came-back edge case. The query below handles both.

The month-pair approach

The core idea: compare each customer's MRR this month to their MRR last month. The delta tells you which bucket they fall into.

  • New: customer had no MRR last month, has MRR this month (and has never appeared before)
  • Expansion: customer's MRR increased
  • Contraction: customer's MRR decreased but is still positive
  • Churn: customer had MRR last month, has zero (or no row) this month
  • Reactivation: customer had no MRR last month, has MRR this month, but has appeared in a prior month

You need a table with one row per customer per month. If you're working from raw Stripe subscriptions, see How to Calculate Real MRR from the Stripe API for the normalization step. For the rest of this post, I'll assume you have:

-- customer_mrr(customer_id, month, mrr)
-- One row per customer per active month. month is a DATE (first of month).
-- Customers with $0 MRR should NOT have a row — absence = no revenue.

This table is the foundation for most SaaS metrics. The NRR calculation, cohort retention, and LTV analysis all start here.

The complete waterfall query

WITH months AS (
  SELECT DISTINCT month FROM customer_mrr
),
-- Full outer join: catches both churn (prev exists, curr doesn't)
-- and new/reactivation (curr exists, prev doesn't)
paired AS (
  SELECT
    COALESCE(curr.month, prev.month + INTERVAL '1 month') AS month,
    COALESCE(curr.customer_id, prev.customer_id)          AS customer_id,
    COALESCE(prev.mrr, 0)                                 AS prev_mrr,
    COALESCE(curr.mrr, 0)                                 AS curr_mrr
  FROM customer_mrr prev
  FULL OUTER JOIN customer_mrr curr
    ON  prev.customer_id = curr.customer_id
    AND curr.month = prev.month + INTERVAL '1 month'
  WHERE COALESCE(prev.mrr, 0) > 0
     OR COALESCE(curr.mrr, 0) > 0
),
-- Detect whether a customer has ever had MRR before this month
first_appearance AS (
  SELECT customer_id, MIN(month) AS first_month
  FROM customer_mrr
  GROUP BY customer_id
),
classified AS (
  SELECT
    p.month,
    p.customer_id,
    p.prev_mrr,
    p.curr_mrr,
    CASE
      WHEN p.prev_mrr = 0 AND p.curr_mrr > 0 AND f.first_month = p.month
        THEN 'new'
      WHEN p.prev_mrr = 0 AND p.curr_mrr > 0 AND f.first_month < p.month
        THEN 'reactivation'
      WHEN p.curr_mrr > p.prev_mrr AND p.prev_mrr > 0
        THEN 'expansion'
      WHEN p.curr_mrr < p.prev_mrr AND p.curr_mrr > 0
        THEN 'contraction'
      WHEN p.curr_mrr = 0 AND p.prev_mrr > 0
        THEN 'churn'
      ELSE 'unchanged'
    END AS movement_type
  FROM paired p
  LEFT JOIN first_appearance f ON p.customer_id = f.customer_id
)
 
SELECT
  month,
  SUM(CASE WHEN movement_type = 'new'
      THEN curr_mrr ELSE 0 END)                           AS new_mrr,
  SUM(CASE WHEN movement_type = 'reactivation'
      THEN curr_mrr ELSE 0 END)                           AS reactivation_mrr,
  SUM(CASE WHEN movement_type = 'expansion'
      THEN curr_mrr - prev_mrr ELSE 0 END)                AS expansion_mrr,
  SUM(CASE WHEN movement_type = 'contraction'
      THEN prev_mrr - curr_mrr ELSE 0 END)                AS contraction_mrr,
  SUM(CASE WHEN movement_type = 'churn'
      THEN prev_mrr ELSE 0 END)                           AS churned_mrr,
  -- Net new = new + reactivation + expansion - contraction - churn
  SUM(CASE WHEN movement_type = 'new' THEN curr_mrr
           WHEN movement_type = 'reactivation' THEN curr_mrr
           WHEN movement_type = 'expansion' THEN curr_mrr - prev_mrr
           WHEN movement_type = 'contraction' THEN -(prev_mrr - curr_mrr)
           WHEN movement_type = 'churn' THEN -prev_mrr
           ELSE 0 END)                                     AS net_new_mrr,
  SUM(curr_mrr)                                            AS ending_mrr
FROM classified
WHERE month IN (SELECT month FROM months)
GROUP BY month
ORDER BY month;

A few things worth calling out. The FULL OUTER JOIN is doing the heavy lifting — a LEFT JOIN from previous to current catches churn but misses brand-new customers who had no previous-month row. The first_appearance CTE separates genuinely new customers from reactivations. And the WHERE month IN (SELECT month FROM months) filters out phantom future months generated by the prev.month + INTERVAL '1 month' arithmetic.

Fastero

Connect your database. Ask questions. Get dashboards.

Postgres, BigQuery, Snowflake, and 10+ sources — live-connected, AI-powered, no dashboard builder learning curve.

Try free →

The same-month upgrade-and-downgrade gotcha

A customer on the $50 plan upgrades to $200 mid-month, then downgrades to $100 before month-end. Your billing system might record two plan changes. If you classify each change independently, you'd show $150 expansion and $100 contraction, which nets to $50 but overstates both columns.

The month-pair approach sidesteps this entirely because it only compares the final MRR at the end of each month. Customer started the month at $50, ended at $100 — that's $50 expansion. One movement, one number, no double-counting. This is why you aggregate to customer_mrr at month boundaries first, not at the individual subscription-change level.

The sanity check you should always run

Every waterfall should pass this identity:

starting_mrr + net_new_mrr = ending_mrr

If it doesn't, your query has a bug. Here's the check:

WITH waterfall AS (
  -- (paste the full query above)
),
running AS (
  SELECT
    month,
    ending_mrr,
    net_new_mrr,
    LAG(ending_mrr) OVER (ORDER BY month) AS prev_ending_mrr
  FROM waterfall
)
 
SELECT
  month,
  prev_ending_mrr                                    AS starting_mrr,
  net_new_mrr,
  prev_ending_mrr + net_new_mrr                      AS expected_ending,
  ending_mrr                                         AS actual_ending,
  ending_mrr - (prev_ending_mrr + net_new_mrr)       AS discrepancy
FROM running
WHERE prev_ending_mrr IS NOT NULL
  AND ABS(ending_mrr - (prev_ending_mrr + net_new_mrr)) > 0.01;

If this returns any rows, something is wrong. The most common cause: customers with $0 MRR still having rows in your customer_mrr table. Remove them or filter to mrr > 0 in the base CTE.

Handling annual contracts

Annual contracts need normalization before they enter the waterfall. A customer paying $12,000/year is $1,000/month in MRR terms. If you don't normalize, a single annual renewal spikes your "new" or "expansion" bucket by 12x.

Build the normalization into your customer_mrr table:

-- Build customer_mrr from raw subscriptions, normalizing annual to monthly
INSERT INTO customer_mrr (customer_id, month, mrr)
SELECT
  s.customer_id,
  gs.month,
  CASE
    WHEN s.billing_interval = 'year'
      THEN s.plan_amount / 12.0
    WHEN s.billing_interval = 'quarter'
      THEN s.plan_amount / 3.0
    ELSE s.plan_amount
  END AS mrr
FROM subscriptions s
CROSS JOIN LATERAL generate_series(
  DATE_TRUNC('month', s.created),
  DATE_TRUNC('month', COALESCE(s.canceled_at, CURRENT_DATE)),
  INTERVAL '1 month'
) AS gs(month)
WHERE s.status IN ('active', 'past_due', 'canceled')
GROUP BY s.customer_id, gs.month
-- If a customer has multiple subs, sum them
HAVING SUM(
  CASE
    WHEN s.billing_interval = 'year' THEN s.plan_amount / 12.0
    WHEN s.billing_interval = 'quarter' THEN s.plan_amount / 3.0
    ELSE s.plan_amount
  END
) > 0;

The generate_series expands each subscription into its active months. A customer with a $6,000 annual plan created in January and canceled in October gets rows for Jan through Oct, each with $500 MRR. The waterfall query doesn't need to know the original billing interval at all.

One thing to watch: if you're on MySQL, generate_series isn't available. You'll need a calendar table or a recursive CTE to produce the month series. DuckDB and Postgres both support it natively.

Mid-month proration

When a customer upgrades from $100 to $200 on the 15th of the month, the invoice for that month might show $100 (first half) + $100 prorated (second half) = $200 total. But the MRR is $200 going forward, not $200 for that transitional month.

The rule: MRR is always the recurring rate as of month-end, not the amount actually invoiced. Invoices include one-time adjustments, proration credits, and setup fees that aren't recurring. Mixing invoice amounts into MRR is the second most common waterfall bug after the double-counting problem.

If you're pulling from Stripe, use subscription.items.price.unit_amount (the current recurring price), not invoice.amount_paid. The Stripe subscription analysis post goes deeper on extracting clean recurring amounts from Stripe's data model.

From query to dashboard

The waterfall query gives you a table. What your CFO wants is a stacked bar chart where green bars go up (new, expansion, reactivation) and red bars go down (contraction, churn), with a net line showing the trend. That's the bridge chart.

Fastero's Stripe integration syncs your subscription data and lets you run the waterfall query directly against it in the SQL editor. From there, you can pin the results to a dashboard widget — stacked bar for the movements, number tile for current MRR. Pair it with the revenue leak detection module and you'll get alerts when churn or contraction spikes, rather than discovering it next month when someone asks why MRR is flat.

The waterfall is one of those queries worth maintaining carefully. When the number matches Stripe's dashboard and your finance team's spreadsheet, you stop having the "which MRR is right" conversation entirely.


Try Fastero free — connect Stripe, build MRR waterfall dashboards, and get alerts when revenue movements shift. No credit card required.

Ready to try it yourself?

Connect your database, ask questions in plain English, and get live dashboards — in under 2 minutes. No credit card required.