SaaS Subscriptions SQL Practice Dataset
Organizations, plan intervals, invoices, and product usage for a B2B SaaS business.
- Tables
- 6
- Rows
- 259,208
- Size
- Large
- Level
- Advanced
- Domain
- Finance
- Formats
- Parquet · Schema SQL
- In browser
- 14,965 rows in the browser
Download this dataset, free
No account, no email, no attribution required:
dedicated to the public domain under CC0 1.0, so commercial use and
redistribution are fine.
Open it in the browser playground, or download the Parquet files below and run saas_subscriptions.schema.sql in DuckDB to create the tables and load them.
This is a large dataset. The downloads here are the complete 259,208 rows as Parquet. Opening it in the playground loads a 14,965-row sample instead, so the browser tab stays responsive. The schema is identical, so a query you write against the sample runs unchanged against the full download.
Three years of a B2B software business: who signed up, what they pay for, when they cancelled, and what they actually do in the product. The schema is ordinary. The difficulty is that a subscription is an interval, not an event, and almost every question worth asking is about a moment in time that the interval happens to cover.
"How much revenue was live in March?" has no row to count. A subscription
that started in January and is still running contributes to March without
having anything dated March anywhere. The join is a comparison of two ranges
(started_on <= month_end and
(ended_on is null or ended_on > month_start)) rather than an
equality on a key, and getting either boundary wrong by a single day is the
usual reason a subscription metric cannot be reconciled with the invoices.
ended_on is null while a subscription is live, and that null is
load-bearing in both directions. Treat it as "ended" and monthly revenue
collapses to nothing. Compare it with a date and every live subscription
silently drops out of the result, because
null > '2025-03-01' is not false, it is unknown.
An organization can hold several subscriptions over time: upgrades,
downgrades, and win-backs after a gap of months. Counting subscriptions is
therefore not counting customers, and the gaps are what separate churn from
retention: a customer who left in April and returned in September was lost
and regained, which a single count(distinct organization_id)
can never show you. No organization ever holds two overlapping intervals, so
the arithmetic always resolves; the generator asserts that on every build.
Invoices are the reality check. Around one in eight was never collected
(failed, refunded, or still open), so invoiced revenue and collected revenue
are different numbers, and only one of them pays anyone.
usage_events.user_id is null for anything a scheduled job or an
API key did rather than a person, which is roughly one event in seven.
All of it is synthetic. No real company, person, subscription, or invoice is represented.
Schema
Join conditions
- users.organization_id = organizations.organization_id
- subscriptions.organization_id = organizations.organization_id
- subscriptions.plan_id = plans.plan_id
- invoices.subscription_id = subscriptions.subscription_id
- usage_events.organization_id = organizations.organization_id
- usage_events.user_id = users.user_id
Schema · SaaS Subscriptions View dataset schema 6 tables
The price list. A plan charges a base price plus a per-seat price, so a subscription's monthly value depends on its seat count as well as its plan.
One row per customer account. signed_up_on is when the organization first appeared, which is not necessarily when its current subscription began.
Seats inside an organization. deactivated_on is null while the user is active; a deactivated user keeps their usage history.
A plan held by an organization over an interval. ended_on is null while the subscription is live. An organization can hold several over time, so counting subscriptions is not counting customers.
One invoice per billing period of a subscription. status is not decoration: failed and refunded invoices were issued but never collected.
Product activity. user_id is null for events raised by the system or an API key rather than a person, so an inner join to users drops them.
Best for
- MRR and active-customer counts, which need an interval overlap rather than a date equality.
- Churn and retention cohorts, including organizations that cancelled and came back.
- Telling invoiced revenue from collected revenue once failed invoices are in the data.
Starter questions
- How much MRR was live at the end of each month?
- Which signup cohorts are still subscribed a year later?
- How much invoiced revenue was never collected?
Practice questions
Work these in the playground against the browser sample. Each link opens the dataset with the question in the editor, ready for your query. Solutions are collapsed, so open one only after you have an answer of your own.
Beginner
-
1. How many organizations are there in each industry, biggest first?
GROUP BY on a dimension table before any interval logic.
Show solution
select industry, count(*) as organizations from organizations group by industry order by organizations desc;
-
2. How many subscriptions are still live? A live subscription has no end date.
IS NULL as the definition of 'current', which is this dataset's central idea.
Show solution
select count(*) as live_subscriptions from subscriptions where ended_on is null;
-
3. How many invoices are in each status, and what do they total?
Seeing that invoiced and collected are different numbers.
Show solution
select status, count(*) as invoices, round(sum(amount), 2) as total from invoices group by status order by total desc; -
4. What is the average number of seats on subscriptions of each plan tier?
Joining a fact to its price list and averaging per group.
Show solution
select p.tier, count(*) as subscriptions, round(avg(s.seats), 1) as avg_seats from subscriptions s join plans p on p.plan_id = s.plan_id group by p.tier order by avg_seats desc; -
5. How many usage events were raised by the system rather than a person, by event type?
Filtering on IS NULL across a nullable foreign key.
Show solution
select event_type, count(*) as system_events from usage_events where user_id is null group by event_type order by system_events desc;
Intermediate
-
1. What was live monthly recurring revenue at the end of each month? A subscription counts for a month if it had started and had not yet ended.
The interval overlap this dataset exists for. The month has no matching row; you have to ask which intervals cover it.
Show solution
with months as ( select distinct date_trunc('month', period_start) as month from invoices ), month_ends as ( select month, month + interval 1 month - interval 1 day as month_end from months ), live as ( select m.month, p.monthly_price + p.seat_price * s.seats as mrr from month_ends m join subscriptions s on s.started_on <= m.month_end and (s.ended_on is null or s.ended_on > m.month_end) join plans p on p.plan_id = s.plan_id ) select month, count(*) as live_subscriptions, round(sum(mrr), 2) as mrr from live group by month order by month; -
2. For each month of issue, how much was invoiced and how much was actually collected?
Conditional aggregation that separates a headline number from the money that arrived.
Show solution
select date_trunc('month', issued_on) as month, round(sum(amount), 2) as invoiced, round(sum(amount) filter (where status = 'paid'), 2) as collected, round(sum(amount) filter (where status <> 'paid'), 2) as uncollected from invoices group by month order by month; -
3. Why do organizations cancel? Count the cancel reasons, and give each as a share of all cancellations.
A window aggregate for the share of total, alongside a plain GROUP BY count.
Show solution
select cancel_reason, count(*) as cancellations, round(100.0 * count(*) / sum(count(*)) over (), 1) as pct_of_cancellations from subscriptions where cancel_reason is not null group by cancel_reason order by cancellations desc; -
4. Which organizations have held more than one subscription? Return the organization, how many it has held, and whether one is still live.
Counting subscriptions is not counting customers, and BOOL_OR answers 'any of them' over a group.
Show solution
select o.org_name, count(*) as subscriptions, bool_or(s.ended_on is null) as currently_subscribed from subscriptions s join organizations o on o.organization_id = s.organization_id group by o.org_name having count(*) > 1 order by subscriptions desc, o.org_name; -
5. Which organizations generate the most product usage? Return the organization, its event count, and how many distinct people were active.
COUNT DISTINCT over a nullable column, which ignores the system events rather than counting them as a person.
Show solution
select o.org_name, count(*) as events, count(distinct e.user_id) as active_users from usage_events e join organizations o on o.organization_id = e.organization_id group by o.org_name order by events desc limit 20;
Advanced
-
1. Take each organization's first subscription month as its cohort. For each cohort, how many organizations were subscribed at the start of the month six months later?
A cohort is a self-join in disguise: the first interval defines the group, a later interval tests survival.
Show solution
with first_sub as ( select organization_id, date_trunc('month', min(started_on)) as cohort_month from subscriptions group by organization_id ), still_on as ( select f.organization_id, f.cohort_month, max(case when s.started_on <= (f.cohort_month + interval 6 month) and (s.ended_on is null or s.ended_on > (f.cohort_month + interval 6 month)) then 1 else 0 end) as retained from first_sub f join subscriptions s on s.organization_id = f.organization_id group by f.organization_id, f.cohort_month ) select cohort_month, count(*) as organizations, sum(retained) as retained_after_6_months, round(100.0 * sum(retained) / count(*), 1) as retention_pct from still_on group by cohort_month order by cohort_month; -
2. Find the organizations that cancelled and later came back. Return the gap in days between the end of one subscription and the start of the next.
LAG across a partition of intervals, which is how a gap becomes visible at all.
Show solution
with ordered as ( select organization_id, started_on, ended_on, lag(ended_on) over (partition by organization_id order by started_on) as previous_ended_on from subscriptions ) select o.org_name, ordered.previous_ended_on, ordered.started_on, date_diff('day', ordered.previous_ended_on, ordered.started_on) as gap_days from ordered join organizations o on o.organization_id = ordered.organization_id where ordered.previous_ended_on is not null and ordered.started_on > ordered.previous_ended_on order by gap_days desc limit 25; -
3. For organizations that have held more than one subscription, compare the monthly value of their first subscription with their latest. Who expanded and who contracted?
Ordered row numbers identify the first and latest subscription values without pretending this is a full period-based retention calculation.
Show solution
with valued as ( select s.organization_id, s.started_on, p.monthly_price + p.seat_price * s.seats as mrr, row_number() over (partition by s.organization_id order by s.started_on) as seq, count(*) over (partition by s.organization_id) as total from subscriptions s join plans p on p.plan_id = s.plan_id ) select o.org_name, round(max(case when seq = 1 then mrr end), 2) as first_mrr, round(max(case when seq = total then mrr end), 2) as latest_mrr, round(max(case when seq = total then mrr end) - max(case when seq = 1 then mrr end), 2) as change from valued v join organizations o on o.organization_id = v.organization_id where total > 1 group by o.org_name order by change desc; -
4. How is a failed invoice associated with cancellation in this dataset? Compare the cancellation rate of subscriptions that had a failed invoice against those that never did.
Conditional aggregation over a derived flag, which turns two populations into one comparable result.
Show solution
with flagged as ( select s.subscription_id, s.ended_on, max(case when i.status = 'failed' then 1 else 0 end) as had_failure from subscriptions s left join invoices i on i.subscription_id = s.subscription_id group by s.subscription_id, s.ended_on ) select case when had_failure = 1 then 'had a failed invoice' else 'never failed' end as cohort, count(*) as subscriptions, count(*) filter (where ended_on is not null) as cancelled, round(100.0 * count(*) filter (where ended_on is not null) / count(*), 1) as cancel_pct from flagged group by had_failure order by cancel_pct desc; -
5. Which organizations are paying but not using the product? Return live subscriptions whose organization raised no usage event in the last 90 days of the data.
An anti-join against a moving date window, a common shape for at-risk-account reports.
Show solution
with cutoff as ( select max(occurred_at) - interval 90 day as since from usage_events ), recent as ( select distinct organization_id from usage_events, cutoff where occurred_at >= cutoff.since ) select o.org_name, p.plan_name, s.seats from subscriptions s join organizations o on o.organization_id = s.organization_id join plans p on p.plan_id = s.plan_id where s.ended_on is null and not exists (select 1 from recent r where r.organization_id = s.organization_id) order by s.seats desc;
Practice with this dataset
Guided lessons for this dataset are still being written. In the meantime, open it in the playground: the tables are already loaded, and the starter query is waiting in the editor.
Open SaaS Subscriptions in the playground →More practice datasets
Accounts, merchants, and two years of posted transactions with no stored balance to read off.
Customers, products, orders, line items, payments, and returns at realistic online-retail volume.
Sessions, pageviews, events, users, and campaigns for funnel and attribution practice.
Channels, campaigns, ad groups, and two years of daily performance where every metric is a ratio.
Student, enrollment, and grade records merged from three schools, with the mixed formats, padding, and duplicates left to clean.