ExpertAnalytics Patterns50 points

The net new MRR waterfall

Investors at Cloudpeak do not only ask for MRR, they ask why it moved. Every month the change splits into four movements: new (the first subscription row of an account), expansion (an upgrade: the new row has a higher mrr than the previous row of that account), contraction (a downgrade) and churn (a row whose end_date has no follow-up row for that account). A movement belongs to the month of the start_date of the new row, or of the end_date for churn.

Dataset: Cloudpeak. Every plan change writes a new subscriptions row: the old row gets an end_date and the new row starts on that same date. A row with end_date NULL is the current plan. A customer who cancels has a last row whose end_date has no follow-up row.

Task: for every month from 2026-01 to 2026-06 (generate them with a recursive CTE so quiet months show zeros) return month (YYYY-MM), new_mrr, expansion_mrr (the increase in mrr), contraction_mrr (the decrease, as a positive number), churned_mrr (the lost mrr, as a positive number) and net_new_mrr = new + expansion - contraction - churned. Use 0, not NULL, for empty cells. Sort by month.

The data you can query

Each table is shown with its column types and the first rows, so you can see exactly what the data looks like.

accounts40 rows
id INTEGERcompany TEXTcountry TEXTindustry TEXTsignup_date TEXT
1Aurora FreightGermanyLogistics2025-09-22
2Bluegrid EnergyUnited KingdomEnergy2025-10-13
3Cedar Health LabsUnited StatesHealthcare2026-03-07
4Delta Pay SystemsNigeriaFintech2025-12-26
5Evergreen Retail GroupCanadaRetail2025-02-12
6Falcon AerospaceUnited Arab EmiratesAerospace2025-02-12

Showing the first 6 of 40 rows.

events149 rows
id INTEGERaccount_id INTEGERuser_id INTEGERfeature TEXTevent_ts TEXT
1112api2025-09-24 16:05:34
2113api2026-04-29 12:15:40
3221api2025-10-15 15:14:48
4224sso2025-10-14 13:06:32
5224alerts2025-10-16 10:57:11
6333alerts2026-06-01 10:37:06

Showing the first 6 of 149 rows.

invoices168 rows
id INTEGERaccount_id INTEGERissued_on TEXTdue_on TEXTpaid_on TEXTamount_usd REAL
112026-01-012026-01-312026-01-22499
212026-02-012026-03-032026-02-13499
312026-03-012026-03-312026-04-23499
412026-04-012026-05-012026-04-23499
512026-05-012026-05-312026-05-20499
612026-06-012026-07-01NULL499

Showing the first 6 of 168 rows.

subscriptions65 rows
id INTEGERaccount_id INTEGERplan TEXTmrr REALstart_date TEXTend_date TEXT
11Scale4992025-09-22NULL
22Starter492025-10-13NULL
33Growth1992026-03-07NULL
44Growth1992025-12-26NULL
55Starter492025-02-122026-02-01
65Growth1992026-02-012026-05-01

Showing the first 6 of 65 rows.

tickets40 rows
id INTEGERaccount_id INTEGERopened_ts TEXTresolved_ts TEXTpriority TEXT
122025-11-28 08:44:00NULLNormal
2372026-01-29 15:51:002026-01-31 09:55:00Low
3182025-05-01 13:33:002025-05-04 09:13:00Low
4112025-09-24 16:09:002025-09-24 19:40:00Normal
5392025-12-06 15:34:002025-12-07 14:17:00High
6152026-03-20 11:06:002026-03-23 20:44:00Low

Showing the first 6 of 40 rows.

Your query runs on SQLite. Write a single SELECT; use LIMIT instead of TOP.

Need a hint?

Build one list of movements first (a self join finds the previous row, NOT EXISTS finds churn), then pivot it with conditional SUM(CASE ...).