A portfolio project by Hamza Mehmood

SaaS product analytics, in plain SQL

Where do new users drop off, which channels bring users who pay, how well do cohorts stick, and what happened the week activity fell? Every number below comes from a query you can read.

What the data says

56%of signups create a workspace; of those, 17% upgrade
17.1%upgrade rate for referral signups, vs 3.4% for paid_social (5.0x)
5.4xupgrade rate for users who invite a teammate (32% vs 6%)
-34%weekly active users in the bad-release week, then +52% the week after

1. Signup funnel

A user counts at a step only after reaching the one before it.

Signed up5,000 (100.0%)
Created a workspace2,785 (55.7%)
Upgraded to paid484 (9.7%)
Show the SQL
-- Signup -> workspace -> upgrade funnel. A user counts at a step only if they
-- reached the previous step first, so the numbers can only go down.
WITH step AS (
    SELECT user_id, event_name, MIN(event_ts) AS ts
    FROM events
    WHERE event_name IN ('signed_up', 'created_workspace', 'upgraded')
    GROUP BY user_id, event_name
),
journey AS (
    SELECT s1.ts AS t1, s2.ts AS t2, s3.ts AS t3
    FROM step s1
    LEFT JOIN step s2 ON s2.user_id = s1.user_id
                     AND s2.event_name = 'created_workspace' AND s2.ts >= s1.ts
    LEFT JOIN step s3 ON s3.user_id = s1.user_id
                     AND s3.event_name = 'upgraded' AND s2.ts IS NOT NULL AND s3.ts >= s2.ts
    WHERE s1.event_name = 'signed_up'
),
counts AS (
    SELECT 1 AS step_no, 'Signed up' AS step, COUNT(t1) AS users FROM journey
    UNION ALL SELECT 2, 'Created a workspace', COUNT(t2) FROM journey
    UNION ALL SELECT 3, 'Upgraded to paid', COUNT(t3) FROM journey
)
SELECT step_no,
       step,
       users,
       ROUND(100.0 * users / FIRST_VALUE(users) OVER (ORDER BY step_no), 1) AS pct_of_signups,
       ROUND(100.0 * users / LAG(users) OVER (ORDER BY step_no), 1)         AS pct_of_previous_step
FROM counts
ORDER BY step_no;

2. Which channels bring users who pay?

channelusersactivatedupgradedactivation rateupgrade raterank by upgrade
referral76653513169.8%17.1%1
organic2,0011,21920160.9%10.0%2
paid_search1,23468711855.7%9.6%3
paid_social9993443434.4%3.4%4
Show the SQL
-- Which acquisition channel brings users who actually convert?
-- Ranked by upgrade rate (upgraded users / all signups from that channel).
WITH per_user AS (
    SELECT u.user_id,
           u.channel,
           MAX(e.event_name = 'created_workspace') AS activated,
           MAX(e.event_name = 'upgraded')          AS upgraded
    FROM users u
    LEFT JOIN events e ON e.user_id = u.user_id
    GROUP BY u.user_id, u.channel
),
by_channel AS (
    SELECT channel,
           COUNT(*)       AS users,
           SUM(activated) AS activated,
           SUM(upgraded)  AS upgraded
    FROM per_user
    GROUP BY channel
)
SELECT channel,
       users,
       activated,
       upgraded,
       ROUND(100.0 * activated / users, 1) AS activation_rate,
       ROUND(100.0 * upgraded / users, 1)  AS upgrade_rate,
       RANK() OVER (ORDER BY 1.0 * upgraded / users DESC) AS rank_by_upgrade
FROM by_channel
ORDER BY rank_by_upgrade, channel;

3. Do users who invite a teammate upgrade more?

Invited a teammate32.4% (388 of 1,197)
Did not invite6.0% (96 of 1,588)

This is an association. People who invite teammates may already be the more committed ones, so the right next step is an experiment (nudge inviting, then compare), not a conclusion.

Show the SQL
-- Among users who created a workspace: do those who invited a teammate
-- upgrade more often? (An association, not proof that inviting causes upgrades.)
WITH activated AS (
    SELECT DISTINCT user_id FROM events WHERE event_name = 'created_workspace'
),
flags AS (
    SELECT a.user_id,
           EXISTS (SELECT 1 FROM events e
                   WHERE e.user_id = a.user_id AND e.event_name = 'invited_teammate') AS invited,
           EXISTS (SELECT 1 FROM events e
                   WHERE e.user_id = a.user_id AND e.event_name = 'upgraded')         AS upgraded
    FROM activated a
)
SELECT CASE invited WHEN 1 THEN 'Invited a teammate' ELSE 'Did not invite' END AS segment,
       COUNT(*)      AS users,
       SUM(upgraded) AS upgraded,
       ROUND(100.0 * SUM(upgraded) / COUNT(*), 1) AS upgrade_rate
FROM flags
GROUP BY invited
ORDER BY invited DESC;

4. Weekly active users

020040060080010001200bad release week (-33.9%)01-0602-0303-0303-3104-2805-2606-23

The activity drop and rebound lines up with the planted bad release. Signups end on 29 June, so the last week falls for that reason and should not be read as churn.

Show the SQL
-- Weekly active users (anyone with a session) and week-over-week change.
WITH weekly AS (
    SELECT date(event_ts, 'weekday 0', '-6 days') AS week_start,
           COUNT(DISTINCT user_id)                AS wau
    FROM events
    WHERE event_name = 'session'
    GROUP BY week_start
)
SELECT week_start,
       wau,
       ROUND(100.0 * (wau - LAG(wau) OVER (ORDER BY week_start))
                   / LAG(wau) OVER (ORDER BY week_start), 1) AS wow_pct
FROM weekly
ORDER BY week_start;

5. Cohort retention

Share of each signup-week cohort with at least one session N weeks later. Look for the diagonal dip in the incident week.

CohortUsersW0W1W2W3W4W5W6W7W8W9W10W11W12
2025-01-0619810036302524232219221818169
2025-01-13199100383029302323241917191814
2025-01-20198100362833332528222022201821
2025-01-27195100332227252327232019121816
2025-02-03207100343531302525222519191412
2025-02-10190100363431322426221922211815
2025-02-1718910035323628252225231725207
2025-02-24203100363234272426212217201111
2025-03-0320010044383228362424172282518
2025-03-1022810039333232252524219191318
2025-03-17180100312928262423181121211217
2025-03-24200100343031282822132016151516
2025-03-31191100373134283715272123172018
2025-04-07208100322433311421171823161711
2025-04-142211003229251623212120201716
2025-04-2122210034341727282724212120
2025-04-28195100352032342220221822
2025-05-051941001934272931251819
2025-05-1220910032363226282220
2025-05-19201100383128352726
2025-05-261871003636343426
2025-06-0219610039312633
2025-06-09217100364029
2025-06-161611003434
2025-06-2321110033
Show the SQL
-- Weekly cohort retention. A cohort is everyone who signed up in the same
-- Monday-to-Sunday week; "retained in week N" means at least one session
-- N weeks after the cohort week started. Weeks that have not happened yet
-- (relative to the last event in the data) are left out, not shown as zero.
WITH cohort AS (
    SELECT user_id, date(event_ts, 'weekday 0', '-6 days') AS cohort_week
    FROM events
    WHERE event_name = 'signed_up'
),
data_end AS (
    SELECT MAX(date(event_ts)) AS last_day FROM events
),
sizes AS (
    SELECT cohort_week, COUNT(*) AS cohort_size
    FROM cohort
    GROUP BY cohort_week
),
weeks(n) AS (
    SELECT 0
    UNION ALL
    SELECT n + 1 FROM weeks WHERE n < 30
),
grid AS (
    SELECT s.cohort_week, s.cohort_size, w.n AS week_no
    FROM sizes s
    CROSS JOIN weeks w
    CROSS JOIN data_end d
    WHERE date(s.cohort_week, '+' || (w.n * 7) || ' days') <= d.last_day
),
active AS (
    SELECT c.cohort_week,
           CAST((julianday(date(e.event_ts)) - julianday(c.cohort_week)) / 7 AS INTEGER) AS week_no,
           COUNT(DISTINCT e.user_id) AS retained
    FROM events e
    JOIN cohort c ON c.user_id = e.user_id
    WHERE e.event_name = 'session'
    GROUP BY c.cohort_week, week_no
)
SELECT g.cohort_week,
       g.cohort_size,
       g.week_no,
       COALESCE(a.retained, 0) AS retained,
       ROUND(100.0 * COALESCE(a.retained, 0) / g.cohort_size, 1) AS retention_pct
FROM grid g
LEFT JOIN active a ON a.cohort_week = g.cohort_week AND a.week_no = g.week_no
ORDER BY g.cohort_week, g.week_no;