SQL KPI分析 — DAU/MAU・CVR・リテンションの基礎

基礎KPI分析DAU / MAUコンバージョン率リテンション率 / ARPUCOUNT DISTINCT / LAGPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

DAU — COUNT(DISTINCT) × GROUP BY で日次アクティブユーザー数を計算する

COUNT DISTINCTGROUP BYDAU重複除外
前提知識

DAU(Daily Active Users)はサービスの「日々の熱量」を測る最も基本的な KPI です。同日に何度行動しても 1 としてカウントする ため、COUNT(DISTINCT user_id) が必須です。

COUNT(DISTINCT user_id)  -- ユニークユーザー数(重複除去) ← DAU に使う
COUNT(user_id)           -- イベント総数(重複あり)       ← DAU には使えない
COUNT(*)                -- 行数(NULL含む重複あり)        ← DAU には使えない
「アクティブ」の定義はプロダクトごとに異なる:「ログインしただけ」を active とするか「特定機能を利用した」だけを active とするかは要件次第です。WHERE event_type IN ('purchase','search') のように絞ってから COUNT(DISTINCT) するのが実務の定番です。
問題

user_events テーブルから、各日付の DAU(日次アクティブユーザー数)を計算してください。出力列は event_date, dau、event_date 昇順で返してください。

使用テーブル
► user_events(9行)
event_dateuser_idevent_type
2024-01-01U1view
2024-01-01U2click
2024-01-01U1purchase
2024-01-02U2view
2024-01-02U3view
2024-01-02U4click
2024-01-03U1view
2024-01-03U3click
2024-01-03U3view
期待出力
event_datedau
2024-01-012
2024-01-023
2024-01-032
模範解答コード
SELECT
  event_date,
  COUNT(DISTINCT user_id) AS dau  -- 同日の重複ユーザーを除去してカウント
FROM  user_events
GROUP BY event_date
ORDER BY event_date;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM user_events                 → 行を読込
  2. GROUP BY event_date              → 日付でグループ化
  3. COUNT(DISTINCT user_id)          → 重複ユーザーを除きカウント
  4. SELECT 2列 / ORDER BY event_date  → 日付昇順で出力
  */
解説(テーブル変化・ポイント)
SELECT event_date, COUNT(DISTINCT user_id) AS dau FROM user_events GROUP BY event_date ORDER BY event_date;
LEGEND
データ取得・読込対象
① FROM user_events(9行)
FROM user_eventsuser_events テーブルを全件読み込みます。U1 は 01-01 に2回、U3 は 01-03 に2回登場しており、COUNT(*) では DAU を過大計上します。
1 / 4
event_dateuser_idevent_type
01-01U1view
01-01U2click
01-01U1purchase
01-02U2view
01-02U3view
01-02U4click
01-03U1view
01-03U3click
01-03U3view
9行読込
学習ポイント
COUNT(*) / COUNT(col) / COUNT(DISTINCT col) の違いを把握する:COUNT(*) は全行(NULL 含む)、COUNT(user_id) は NULL を除く行数(重複あり)、COUNT(DISTINCT user_id) は NULL を除くユニーク数です。DAU は必ず COUNT(DISTINCT user_id) を使います
GROUP BY + 集計関数の論理評価順序:SQL の論理評価順は FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY です。GROUP BY が「バケツ」を作り、SELECT でバケツごとの集計値を計算します。ORDER BY は SELECT の後のため列エイリアス dau が使えます。
DAU をウィンドウ関数・ LAG と組み合わせた拡張:この結果を CTE に入れて LAG(dau) OVER (ORDER BY event_date) を使うと、DAU 前日比・7日移動平均が得られます。Q2 でその LAG パターンを扱います。
アンチパターン
COUNT(*) や COUNT(user_id) で DAU を計算する:同一ユーザーが1日1回アクセスすると COUNT(*) は 10、COUNT(DISTINCT user_id) は 1 になります。イベントログテーブルは必ず COUNT(DISTINCT) で集計してください。
「アクティブ」の定義を WHERE で絞り込まずに集計する:view・click・purchase など全イベントを対象にすると、「アプリを開いただけ」のユーザーも active になります。要件に応じて WHERE event_type = 'session_start' などでフィルタしてから COUNT(DISTINCT) を実行してください。
実務コラム:DAU・WAU・MAU の使い分け
DAU は「今日の熱量」を示す最も粒度の細かい指標です。WAU(Weekly Active Users)は曜日効果を吸収し、MAU(Monthly Active Users)は月単位の規模感を示します。DAU / MAU 比率(スティッキネス) が高いほど習慣的に使われているサービスといえます。dbt の daily モデルで DAU を毎朝更新し、Looker や Metabase でトレンドを可視化するのが現代の定番構成です。
QUESTION 2

MAU前月比成長率 — LAG() × CTE で月次KPIの成長率トレンドを計算する

LAG OVERCTEMAU成長率前月比NULLIF
前提知識

MAU(Monthly Active Users)は「月に1回以上サービスを利用したアクティブユーザー数」を示す、プロダクトの規模と継続性を測る最重要KPIのひとつです。このMAUの月次推移を分析する際、LAG() ウィンドウ関数が活躍します。現在行の N 行前の値を取得できるため、サブクエリや自己結合なしで「前月比」などの成長率を簡単に計算できます。

LAG(mau) OVER (ORDER BY month)
-- ORDER BY month の順で「1つ前の行」の mau を取得
-- 先頭行には前の行がないため NULL を返す

(mau - prev_mau) * 100.0 / NULLIF(prev_mau, 0)
-- NULLIF(prev_mau, 0): prev_mau=0 のとき NULL を返してゼロ除算を防ぐ
-- * 100.0: 整数除算を避けて浮動小数点に変換
CTE(WITH句)で LAG 結果を再利用する:LAG を1回以上書き直すと冗長でミスの温床になります。WITH lagged AS (...) でまず prev_mau 列を作り、外部クエリで prev_mau を参照するパターンが可読性・保守性ともに高いです。
問題

monthly_kpi テーブルから、各月の MAU・前月 MAU(prev_mau)・前月比成長率(mau_growth_pct)を計算してください。出力列は month, mau, prev_mau, mau_growth_pct、month 昇順で返してください。成長率は小数第2位まで丸めてください。

使用テーブル
► monthly_kpi(6行)
monthmau
2024-011200
2024-021380
2024-031450
2024-041390
2024-051560
2024-061820
期待出力
monthmauprev_maumau_growth_pct
2024-011200NULLNULL
2024-021380120015.00
2024-03145013805.07
2024-0413901450-4.14
2024-051560139012.23
2024-061820156016.67
模範解答コード
WITH lagged AS (
  SELECT
    month, mau,
    LAG(mau) OVER (ORDER BY month) AS prev_mau  -- 前月の MAU を取得
  FROM  monthly_kpi
)
SELECT
  month, mau, prev_mau,
  ROUND(
    (mau - prev_mau) * 100.0
    / NULLIF(prev_mau, 0), 2  -- 0 除算を回避(0なら NULL)
  ) AS mau_growth_pct         -- 前月比成長率(%)
FROM  lagged
ORDER BY month;

/*
  実行順序:
  1. FROM monthly_kpi                         → 行を読込
  2. LAG(mau) OVER (ORDER BY month)           → 前月の mau を付与
  3. WITH lagged                              → CTE 完成
  4. (mau-prev_mau)*100.0/NULLIF(prev_mau,0)  → 成長率を計算
  5. ROUND(..., 2) / ORDER BY month           → 丸めて月順に出力
  */
解説(テーブル変化・ポイント)
WITH lagged AS ( SELECT month, mau, LAG(mau) OVER (ORDER BY month) AS prev_mau FROM monthly_kpi ) SELECT month, mau, prev_mau, ROUND( (mau - prev_mau) * 100.0 / NULLIF(prev_mau, 0), 2 ) AS mau_growth_pct FROM lagged ORDER BY month;
LEGEND
データ取得・読込対象
① FROM monthly_kpi(6行)
FROM monthly_kpimonthly_kpi テーブルを全件読み込みます。2024-04 のみ前月より MAU が減少しており、この「マイナス成長」を LAG で正確に捕らえるのが目標です。
1 / 4
monthmau
2024-011200
2024-021380
2024-031450
2024-041390
2024-051560
2024-061820
6行読込
学習ポイント
LAG(col, N, default) の3引数構文:LAG(mau) は N=1・default=NULL のショートハンドです。LAG(mau, 12, 0) とすれば「12ヶ月前(YoY)の mau、なければ 0」が取得できます。前年同月比(YoY)は LEAD/LAG の offset を 12 に変えるだけで実装できます
NULLIF でゼロ除算と NULL 除算を同時にガードする:NULLIF(prev_mau, 0) は prev_mau が 0 のとき NULL を返すため、後続の除算がエラーになりません。PostgreSQL では NULL / 数 = NULL (エラーなし)なので安全です
CTE で prev_mau を一度だけ定義する:CTE を使わずに書くと LAG(mau) を成長率計算で三度繰り返す必要があります。WITH lagged AS で prev_mau を一度定義し、外部クエリで参照するパターンは DRY で保守性が高いです
アンチパターン
OVER() に ORDER BY を書き忘れる:LAG(mau) OVER () は行順が未定義となり、DBエンジンが任意の順序で前行を選びます。LAG/LEAD は必ず OVER (ORDER BY 時系列列) と ORDER BY を明示してください。
整数カラム同士の除算で 0 になる:(1380-1200)/1200 はどちらも integer のため PostgreSQL では 0(整数除算)になります。* 100.0 または ::numeric キャストで必ず浮動小数点演算に変換してください。
実務コラム:MoM・WoW・YoY の使い分け
成長率の分析粒度はビジネスサイクルに合わせます。MoM(Month-over-Month)は月次レポートの定番、WoW(Week-over-Week)はプロモーション効果の計測、YoY(Year-over-Year)は季節変動を吸収したい場合に使います。YoY の LAG は LAG(mau, 12) と offset を 12 に変えるだけです。
QUESTION 3

コンバージョン率 — CASE WHEN × COUNT(DISTINCT) でファネル分析を実装する

CASE WHENCOUNT DISTINCTCVRファネル分析NULLIF
前提知識

コンバージョン率(CVR: Conversion Rate)は、サイト訪問や会員登録などのアクションを起こしたユーザーのうち、最終的な目標(購入や有料化など)を達成した割合を示す指標です。これを各ステップごとに分解して可視化するファネル分析は、ユーザーが「登録 → 体験 → 購入」のどの段階で離脱しているかを定量化する強力な手法です。CVR = ステップ通過者 / 直前ステップ通過者 を計算することで、改善すべきボトルネックを特定できます。

COUNT(DISTINCT CASE WHEN step = 'signup' THEN user_id END)
-- step='signup' の行だけ user_id を返し、それ以外は NULL
-- COUNT(DISTINCT) は NULL を無視するため、signup ユーザー数だけカウント

ROUND(trial_users * 100.0 / NULLIF(signup_users, 0), 2)
CASE WHEN が NULL を返す仕組みを理解する:CASE WHEN step='signup' THEN user_id END は ELSE 句がないため、条件不成立時は自動的に NULL を返します。
問題

funnel_events テーブルから、各ステップのユニークユーザー数と CVR を1行で出力してください。出力列は signup_users, trial_users, purchase_users, trial_cvr, purchase_cvr

使用テーブル
► funnel_events(10行)
user_idstepevent_date
U01signup2024-01-10
U02signup2024-01-10
U03signup2024-01-10
U04signup2024-01-10
U05signup2024-01-10
U01trial_start2024-01-11
U02trial_start2024-01-11
U03trial_start2024-01-12
U01purchase2024-01-15
U02purchase2024-01-16
期待出力
signup_userstrial_userspurchase_userstrial_cvrpurchase_cvr
53260.0066.67
模範解答コード
SELECT
  COUNT(DISTINCT CASE WHEN step = 'signup'
    THEN user_id END)      AS signup_users,  -- 該当ステップのユニーク数(NULLは除外)
  COUNT(DISTINCT CASE WHEN step = 'trial_start'
    THEN user_id END)      AS trial_users,
  COUNT(DISTINCT CASE WHEN step = 'purchase'
    THEN user_id END)      AS purchase_users,
  ROUND(
    COUNT(DISTINCT CASE WHEN step = 'trial_start' THEN user_id END)
    * 100.0 / NULLIF(COUNT(DISTINCT CASE WHEN step = 'signup'
    THEN user_id END), 0), 2
  ) AS trial_cvr,     -- trial / signup の通過率(%)
  ROUND(
    COUNT(DISTINCT CASE WHEN step = 'purchase' THEN user_id END)
    * 100.0 / NULLIF(COUNT(DISTINCT CASE WHEN step = 'trial_start'
    THEN user_id END), 0), 2
  ) AS purchase_cvr  -- purchase / trial の通過率(%)
FROM  funnel_events;

/*
  実行順序:
  1. FROM funnel_events                   → 行を読込
  2. CASE WHEN step='x' THEN user_id END  → 該当ステップのみ user_id を返す
  3. COUNT(DISTINCT ...)                  → NULLをスキップしユニークカウント
  4. CVR を計算                              → 前段比で通過率を算出
  */
解説(テーブル変化・ポイント)
SELECT COUNT(DISTINCT CASE WHEN step='signup' THEN user_id END) AS signup_users, COUNT(DISTINCT CASE WHEN step='trial_start' THEN user_id END) AS trial_users, COUNT(DISTINCT CASE WHEN step='purchase' THEN user_id END) AS purchase_users, ROUND( COUNT(DISTINCT CASE WHEN step='trial_start' THEN user_id END) * 100.0 / NULLIF(COUNT(DISTINCT CASE WHEN step='signup' THEN user_id END), 0), 2 ) AS trial_cvr, ROUND( COUNT(DISTINCT CASE WHEN step='purchase' THEN user_id END) * 100.0 / NULLIF(COUNT(DISTINCT CASE WHEN step='trial_start' THEN user_id END), 0), 2 ) AS purchase_cvr FROM funnel_events;
LEGEND
データ取得・読込対象
① FROM funnel_events(10行)
FROM funnel_eventsfunnel_events テーブルを全件読み込みます。signup 5件、trial_start 3件、purchase 2件。U04・U05 はトライアルに進まず、U03 はトライアルで止まっています。
1 / 4
user_idstepevent_date
U01signup01-10
U02signup01-10
U03signup01-10
U04signup01-10
U05signup01-10
U01trial_start01-11
U02trial_start01-11
U03trial_start01-12
U01purchase01-15
U02purchase01-16
10行読込
学習ポイント
COUNT(DISTINCT CASE WHEN...) は単一テーブル完結のファネル集計:複数の step を持つテーブルを GROUP BY step で分割すると複数行になり、CVR 計算に再度 JOIN が必要になります。このパターンは1クエリで全ステップのユニーク数を横持ちに変換できる最も効率的なパターンです。
CASE WHEN に ELSE を書かないと NULL になる:CASE WHEN step='signup' THEN user_id END は ELSE 句がないため、条件不成立時に自動で NULL を返します。COUNT(DISTINCT) は NULL を無視するため、結果的に signup 行だけを数えるという動作になります。
CVR の分母は「直前ステップ」の通過者数を使う:全体 CVR(purchase/signup=2/5=40%)ではなく、ステップ間 CVR(trial→purchase=2/3=66.67%)を計算することで、どのステップに最大の改善余地があるかが分かります
アンチパターン
COUNT(*) や COUNT(step) でファネルを計算する:同一ユーザーが同じステップを複数回通過すると COUNT(*) は過大計上になります。ファネル分析は必ず COUNT(DISTINCT user_id) を使ってください。
ステップの通過を「厳密な順序」として扱う:実際のユーザー行動では「signup → purchase(trial を飛ばす)」や「trial を複数回繰り返す」ケースがあります。このクエリはステップ通過の有無だけを計測しており、順序は考慮していません。時間窓や順序制約が必要な場合は CTE + self-join やウィンドウ関数で実装してください。
実務コラム:ファネル分析のダッシュボード実装
このクエリを dbt のモデルとして定義し、Metabase や Looker の Funnel チャートに接続するのが実務の定番です。ステップ間 CVR がベースラインを下回ったときに Slack アラートを飛ばす構成にすれば、マーケティング施策の効果検証が自動化できます。
QUESTION 4

リテンション率 — CTE + LEFT JOIN でコホートの D1/D7 リテンションを計算する

CTELEFT JOINリテンション率コホート分析
前提知識

リテンション率は「一度来たユーザーが再び戻ってくる割合」を示す KPI です。D1 リテンション = 初日の翌日も戻ったユーザーの割合、D7 = 7日後も戻った割合です。

WITH first_login AS (
  SELECT user_id, MIN(login_date) AS cohort_date
  FROM  user_logins  GROUP BY user_id
)
-- D1/D7判定
CASE WHEN l.login_date = f.cohort_date + 1 THEN l.user_id END
CASE WHEN l.login_date = f.cohort_date + 7 THEN l.user_id END
LEFT JOIN で「戻ってこなかったユーザー」を保持する:INNER JOIN を使うと D1/D7 に戻ってきたユーザーだけが残り、分母(コホートサイズ)が正しく計算できません
問題

user_logins テーブルから、2024-01-01 コホートの D1・D7 リテンション率を計算してください。出力列は cohort_date, cohort_size, d1_users, d1_retention, d7_users, d7_retention(retention は小数第2位まで丸め)。

使用テーブル
► user_logins(10行)
user_idlogin_date
U12024-01-01
U22024-01-01
U32024-01-01
U42024-01-01
U52024-01-01
U12024-01-02
U32024-01-02
U22024-01-08
U32024-01-08
U52024-01-08
期待出力
cohort_datecohort_sized1_usersd1_retentiond7_usersd7_retention
2024-01-015240.00360.00
模範解答コード
WITH first_login AS (
  SELECT
    user_id,
    MIN(login_date) AS cohort_date  -- 初回ログイン日=コホート
  FROM  user_logins
  GROUP BY user_id
)
SELECT
  f.cohort_date,
  COUNT(DISTINCT f.user_id)                     AS cohort_size,
  COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 1
    THEN l.user_id END)                          AS d1_users,  -- 翌日(+1)に再訪
  ROUND(
    COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 1
      THEN l.user_id END) * 100.0
    / NULLIF(COUNT(DISTINCT f.user_id), 0), 2
  )                                                 AS d1_retention,  -- D1 再訪率(%)
  COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 7
    THEN l.user_id END)                          AS d7_users,
  ROUND(
    COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 7
      THEN l.user_id END) * 100.0
    / NULLIF(COUNT(DISTINCT f.user_id), 0), 2
  )                                                 AS d7_retention
FROM      first_login f
LEFT JOIN user_logins l USING (user_id)  -- コホートに全ログインを結合
GROUP BY  f.cohort_date
ORDER BY  f.cohort_date;

/*
  実行順序:
  1. CTE first_login                         → 各ユーザーの初回ログイン日を集計
  2. LEFT JOIN user_logins                   → コホートに全ログインを結合
  3. CASE WHEN login_date = cohort_date + N  → D1/D7 の再訪を判定
  4. リテンション率を計算                              → 再訪数 / コホートサイズ
  */
解説(テーブル変化・ポイント)
WITH first_login AS ( SELECT user_id, MIN(login_date) AS cohort_date FROM user_logins GROUP BY user_id ) SELECT f.cohort_date, COUNT(DISTINCT f.user_id) AS cohort_size, COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 1 THEN l.user_id END) AS d1_users, ROUND( COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 1 THEN l.user_id END) * 100.0 / NULLIF(COUNT(DISTINCT f.user_id), 0), 2 ) AS d1_retention, COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 7 THEN l.user_id END) AS d7_users, ROUND( COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 7 THEN l.user_id END) * 100.0 / NULLIF(COUNT(DISTINCT f.user_id), 0), 2 ) AS d7_retention FROM first_login f LEFT JOIN user_logins l USING (user_id) GROUP BY f.cohort_date ORDER BY f.cohort_date;
LEGEND
データ取得・読込対象
① FROM user_logins(10行)
FROM user_loginsuser_logins テーブルを全件読み込みます。全ユーザーが 2024-01-01 に初回ログインしていることを確認します。
1 / 8
user_idlogin_date
U101-01
U201-01
U301-01
U401-01
U501-01
U101-02
U301-02
U201-08
U301-08
U501-08
10行読込
学習ポイント
CTE で「コホート定義」と「リテンション計算」を分離する:first_login CTE が各ユーザーの初回ログイン日を定義し、メインクエリが N 日後のログインを検出します。コホート定義を CTE で分離することで、コホート定義(例: 初回購入日)を変えるときも CTE の変更だけで済みます
PostgreSQL での日付演算 date + integerPostgreSQL では DATE型 + 1 で「翌日」が得られます。MySQL は DATE_ADD(login_date, INTERVAL 1 DAY)、SQL Server は DATEADD(day,1,login_date) と書き方が異なります。
LEFT JOIN で「戻らなかったユーザー」を分母に含める:U4 は 01-01 以降一切ログインしていませんが、LEFT JOIN により first_login 側の行(U4, 2024-01-01)は保持されます。これにより cohort_size = 5(U4 を含む) となり、D1/D7 の分母が正確になります。
アンチパターン
INNER JOIN を使って「戻ってきたユーザーしか残らない」状態にする:INNER JOIN user_logins l にすると U4 のような「戻らなかったユーザー」が除外されます。すると cohort_size が実際より少なくなり、リテンション率が実際より高く見える(楽観バイアス)問題が発生します。
CTE なしで直接 user_logins を自己結合する:first_login CTE を使わずに自己結合するクエリは可読性が低く、バグを生みやすいです。「コホート日の特定」は MIN(login_date) × CTE で明確に分離してください。
実務コラム:リテンション率の業界ベンチマーク
D1 リテンション率は業界ごとに大きく異なります。モバイルゲームでは D1>40% が合格ライン、D7>20% が中堅水準とされています。SaaS(BtoB)では月次リテンション率が主要指標で、>90% が健全とされます。SQL でコホートを切ってリテンションを追跡し、新機能リリース日との相関を Looker や Redash で重ね合わせることが、プロダクト改善サイクルの定番手法です。
QUESTION 5

ARPU — LEFT JOIN + GROUP BY + COALESCE でプランセグメント別平均単価を計算する

LEFT JOINGROUP BYARPUセグメント分析COALESCE
前提知識

ARPU(Average Revenue Per User)は「1ユーザーあたりの平均売上」を示す KPI で、プラン・チャネル・地域などのセグメント別に比較することでマネタイズ施策の優先度判断に使われます。

ARPU = SUM(revenue) / COUNT(DISTINCT user_id)

-- 購入履歴がない users を除外しないため LEFT JOIN を使う
FROM users u LEFT JOIN purchases p USING (user_id)

-- 購入なしユーザーは SUM(amount)=NULL → COALESCE で0に変換
COALESCE(SUM(p.amount), 0)

-- LEFT JOIN 後に user_id が重複するため DISTINCT 必須
COUNT(DISTINCT u.user_id)
JOIN 後の COUNT(DISTINCT) が重要な理由:1ユーザーが複数回購入すると LEFT JOIN で行が展開されます。JOIN 後は必ず COUNT(DISTINCT u.user_id) を使います
問題

userspurchases テーブルから、プラン別のユーザー数・合計売上・ARPU を計算してください。出力列は plan, user_count, total_revenue, arpu、arpu の降順で返してください。購入がないユーザーの売上は 0 として扱ってください。arpu は小数第2位まで丸めてください。

使用テーブル
► users(8行)
user_idplan
U1premium
U2premium
U3standard
U4standard
U5standard
U6free
U7free
U8free
► purchases(6行)
user_idamount
U15000
U13000
U24500
U31500
U42000
U51800
期待出力
planuser_counttotal_revenuearpu
premium2125006250.00
standard353001766.67
free300.00
模範解答コード
SELECT
  u.plan,
  COUNT(DISTINCT u.user_id)           AS user_count,
  COALESCE(SUM(p.amount), 0)         AS total_revenue,
  ROUND(
    COALESCE(SUM(p.amount), 0) * 1.0
    / COUNT(DISTINCT u.user_id),  -- DISTINCT 必須
    2
  )                                   AS arpu
FROM       users     u
LEFT JOIN  purchases p  USING (user_id)
GROUP BY   u.plan
ORDER BY   arpu DESC;

/*
  実行順序:
  1. FROM users u               → 行を読込
  2. LEFT JOIN purchases p      → 購入を結合(未購入はNULL)
  3. GROUP BY u.plan            → プランでグループ化
  4. SUM(p.amount)              → プラン別売上を集計
  5. COALESCE(SUM(...), 0)      → NULL を 0 に変換
  6. COUNT(DISTINCT u.user_id)  → プラン別ユーザー数
  7. ARPU = revenue / count     → 客単価を算出
  8. ORDER BY arpu DESC         → 降順で出力
  */
解説(テーブル変化・ポイント)
SELECT u.plan, COUNT(DISTINCT u.user_id) AS user_count, COALESCE(SUM(p.amount), 0) AS total_revenue, ROUND( COALESCE(SUM(p.amount), 0) * 1.0 / COUNT(DISTINCT u.user_id), 2 ) AS arpu FROM users u LEFT JOIN purchases p USING (user_id) GROUP BY u.plan ORDER BY arpu DESC;
LEGEND
データ取得・読込対象
① FROM users(8行)
FROM users uusers テーブルを全件読み込みます。プランは premium(2人)・standard(3人)・free(3人)の3種類です。
1 / 5
user_idplan
U1premium
U2premium
U3standard
U4standard
U5standard
U6free
U7free
U8free
8行読込
学習ポイント
LEFT JOIN 後は必ず COUNT(DISTINCT u.user_id) を使う:U1 が2回購入すると LEFT JOIN で 2行展開されます。COUNT(u.user_id)(DISTINCT なし)だと U1 が2回カウントされ分母が膨らみ ARPU が低く計算されます。複数行に展開される可能性がある JOIN の後は、常に COUNT(DISTINCT) を使ってください
COALESCE(SUM(amount), 0) で購入ゼログループを正しく扱う:LEFT JOIN の結果、購入がないグループ(free)は SUM(p.amount)=NULLCOALESCE(SUM(amount), 0) は NULL を0 に変換し、ARPU = 0/3 = 0.00 という正しい計算を保証します。
* 1.0 で整数除算を浮動小数点に変換する:COALESCE(SUM(amount), 0) は integer 型のため、* 1.0 または ::numeric キャストで浮動小数点に変換しないと、1766.67 が 1766 に切り捨てられます。
アンチパターン
INNER JOIN で「購入ユーザーだけ」でARPUを計算する:INNER JOIN を使うと free ユーザー(U6/U7/U8)が完全に除外されます。「free プランの ARPU = 0」という正しい事実が消え、正確な全体像を攪乱させます。必ず LEFT JOIN を使ってください。
SUM(amount) / COUNT(*) で計算する:COUNT(*) は JOIN 後の全行数(9行)を返すため、分母が「ユーザー数(8)」ではなく「購入行数+NULLの合計(9)」になります。ARPU の定義「売上÷ユーザー数」を守るために COUNT(DISTINCT u.user_id) を使ってください。
実務コラム:ARPU から LTV へ
ARPU(単月)をベースに LTV(Lifetime Value)= ARPU × 平均継続月数を推計するのが SaaS の標準的なユニットエコノミクス分析です。LTV / CAC(顧客獲得コスト)≥ 3 が健全な SaaS の目安とされます。また、プラン別 ARPU のトレンドを LAG() でモニタリングすることで、価格改定やアップセル施策の効果を即座に定量化できます。