Skip to content
Datasets / SaaS Subscriptions
Dataset

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.

saas_subscriptions.schema.sql All 6 tables, 259,208 rows, as CREATE TABLE and COPY statements that load the files below. Download →

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

How the SaaS Subscriptions tables join plansorganizationssubscriptionsusersinvoicesusage_events plan_idorganization_idsubscription_idorganization_idplan_iduser_idorganization_idinvoice_idsubscription_idevent_idorganization_iduser_id PKPKPKFKFKPKFKPKFKPKFKFK + 6 more columns+ 6 more columns+ 4 more columns+ 4 more columns+ 6 more columns+ 3 more columns
Arrows point from a column to the key it references. PK marks a table's key and FK a column that references another table.
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
Table · plans

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.

7 columns · 8 rows
plan_id int plan_name text tier text monthly_price double seat_price double billing_interval text is_active bool
Table · organizations

One row per customer account. signed_up_on is when the organization first appeared, which is not necessarily when its current subscription began.

7 columns · 50 rows
organization_id int org_name text industry text country text employee_band text signed_up_on date acquisition_channel text
Table · users

Seats inside an organization. deactivated_on is null while the user is active; a deactivated user keeps their usage history.

6 columns · 2325 rows
user_id int organization_id int email text role text joined_on date deactivated_on date
Table · subscriptions

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.

7 columns · 68 rows
subscription_id int organization_id int plan_id int started_on date ended_on date seats int cancel_reason text
Table · invoices

One invoice per billing period of a subscription. status is not decoration: failed and refunded invoices were issued but never collected.

8 columns · 717 rows
invoice_id int subscription_id int issued_on date period_start date period_end date amount double status text paid_on date
Table · usage_events

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.

6 columns · 11797 rows
event_id int organization_id int user_id int occurred_at timestamp event_type text quantity int

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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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 →