SQL 行動分析 — コホート・ファネル・チャーンの基礎

基礎行動分析コホート / リテンションファネル / チャーンウィンドウ関数PostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

コホート分析 — DATE_TRUNC('month') で登録月コホートを定義し初月アクティビティ率を算出する

DATE_TRUNCLEFT JOINコホート分析初月アクティビティ
前提知識

コホート分析は、同じ時期に獲得したユーザー群(コホート)の行動を追跡する手法です。まず DATE_TRUNC('month', registered_at) で登録日を月初日に丸め、同じ登録月のユーザーを1つのコホートとして扱います。

DATE_TRUNC('month', '2023-07-15'::date)  -- → 2023-07-01
DATE_TRUNC('month', '2023-08-22'::date)  -- → 2023-08-01
LEFT JOIN の日付条件がコホート分析の核心:LEFT JOIN の ON 句で、行動月と登録月を同じ粒度に丸めて比較すると、「登録月と同じ月の行動のみ」を結合できます。マッチしない行は右側の識別子が NULL になるため、右側の識別子を DISTINCT COUNT して初月の活動人数を取得します。
問題

userslogin_events テーブルから、登録月コホートごとの初月アクティビティ率を算出してください。取得列は cohort_month, cohort_size, active_in_first_month, first_month_active_pct(小数第1位)、cohort_month 昇順で返してください。

使用テーブル
▸ users
user_idregistered_at
12024-01-10
22024-01-15
32024-01-22
42024-02-05
52024-02-14
62024-02-20
▸ login_events
user_idevent_date
12024-01-12
22024-01-18
32024-02-03
42024-02-07
52024-03-05
62024-02-22
期待出力
cohort_monthcohort_sizeactive_in_first_monthfirst_month_active_pct
2024-01-013266.7
2024-02-013266.7
模範解答コード
SELECT
  DATE_TRUNC('month', u.registered_at)::date  AS cohort_month,
  COUNT(DISTINCT u.user_id)                    AS cohort_size,
  COUNT(DISTINCT e.user_id)                    AS active_in_first_month,  -- NULL は COUNT されない
  ROUND(
    COUNT(DISTINCT e.user_id) * 100.0 /
    COUNT(DISTINCT u.user_id), 1
  ) AS first_month_active_pct
FROM   users u
LEFT JOIN login_events e
  ON  e.user_id = u.user_id
  AND DATE_TRUNC('month', e.event_date)         -- イベントが登録月と同月か判定
    = DATE_TRUNC('month', u.registered_at)
GROUP BY cohort_month
ORDER BY cohort_month;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM users u            → 行を読み込む
  2. LEFT JOIN login_events  → 結合(左表を全行保持)
  3. GROUP BY registered_at  → グループ化
  4. 集計関数を評価          → COUNT(DISTINCT ...)
  5. SELECT                  → 値を整形
  6. ORDER BY cohort_month   → 並び替えて出力
*/
解説(テーブル変化・ポイント)
SELECT DATE_TRUNC('month', u.registered_at)::date AS cohort_month, COUNT(DISTINCT u.user_id) AS cohort_size, COUNT(DISTINCT e.user_id) AS active_in_first_month, ROUND( COUNT(DISTINCT e.user_id) * 100.0 / COUNT(DISTINCT u.user_id), 1 ) AS first_month_active_pct FROM users u LEFT JOIN login_events e ON e.user_id = u.user_id AND DATE_TRUNC('month', e.event_date) = DATE_TRUNC('month', u.registered_at) GROUP BY cohort_month ORDER BY cohort_month;
LEGEND
データ取得・読込対象
① FROM users + login_events(2テーブル)
FROM users u / login_events eusers(6行)と login_events(6行)の2テーブルを読み込みます。LEFT JOIN のため users が基準となり、マッチしない login_events 行は除外されます。
1 / 4
▸ users u
user_idregistered_at
12024-01-10
22024-01-15
32024-01-22
42024-02-05
52024-02-14
62024-02-20
▸ login_events e
user_idevent_date
12024-01-12
22024-01-18
32024-02-03
42024-02-07
52024-03-05
62024-02-22
users: 6行 / login_events: 6行
学習ポイント
JOIN の ON 句に日付条件を入れる理由:WHERE 句に日付条件を書くと LEFT JOIN が事実上 INNER JOIN に変化します。LEFT JOIN の条件は必ず ON 句に書いてください。WHERE に書くと「初月にログインしていないユーザー(NULL 行)」が消えてしまい、コホートサイズが過小評価されます。
COUNT(DISTINCT) が NULL を自動除外する:COUNT(DISTINCT e.user_id) は e.user_id が NULL の行を自動でスキップします。これにより「初月に一度もログインしなかったユーザー」を分母に含め、分子には含めないという正しい計算が実現します。
DATE_TRUNC vs DATE_FORMAT:PostgreSQL の DATE_TRUNC('month', col) は月初日(timestamp 型)を返します。MySQL の DATE_FORMAT(col, '%Y-%m-01') や BigQuery の DATE_TRUNC(col, MONTH) と概念は同じですが、構文が異なります。コホート分析ではどのDBでも「月単位への丸め」が必須です。
アンチパターン
WHERE 句に日付フィルタを書く(LEFT JOIN が INNER JOIN 化):LEFT JOIN login_events e ON e.user_id = u.user_id WHERE DATE_TRUNC(...) と書くと、NULL 行が WHERE で除去されます。初月ログインなしユーザーがカウントから消え、コホートサイズが意図せず縮小します。
INNER JOIN でコホートサイズを計算する:INNER JOIN だと「初月にログインしたユーザーのみ」が結合されるため、cohort_size が active_in_first_month と同じ値になってしまいます。コホート分析では全ユーザーを起点に LEFT JOIN するのが正解です。
実務コラム:コホート分析の使いどころ
コホート分析の最大の価値は「施策の効果を世代別に比較」できる点です。例えばオンボーディングフローを改善した月(2024年3月コホート)と改善前(1月・2月コホート)の初月アクティビティ率を並べることで、施策の効果を定量的に評価できます。チャネル別(SEO流入・広告流入)や料金プラン別にコホートを分割すれば、どのユーザー属性が長期的に活性化するかも把握できます。
QUESTION 2

リテンション分析 — CTE + INTERVAL '1 month' で翌月リテンション率を算出する

CTEINTERVALリテンション分析翌月継続率
前提知識

リテンション分析は、登録月のユーザーが翌月以降も継続して利用しているかを測定します。コホート月の翌月にアクティブだったユーザー数 ÷ コホートサイズ が Month 1 リテンション率です。

指標計算式実務での目安(SaaS)
Month 1 Retention翌月アクティブ数 / コホートサイズ40〜60% が優良
Month 3 Retention3ヶ月後アクティブ数 / コホートサイズ20〜40% が優良
SELECT DATE_TRUNC('month', ts_col) AS month_start,   -- 月初へ丸める
       ts_col + INTERVAL '1 month'  AS next_month,   -- 月を加算する
       ts_col - INTERVAL '7 days'   AS week_ago      -- 日を減算する
FROM   table_name;
INTERVAL の加算:cohort_month + INTERVAL '1 month' は PostgreSQL で月を加算する標準記法です。DATE_TRUNC('month', ...) が返す date 型に加算できます。BigQuery では DATE_ADD(cohort_month, INTERVAL 1 MONTH) と書きます。
問題

userslogin_events テーブルから、登録月コホートごとの翌月リテンション率を算出してください。取得列は cohort_month, cohort_size, retained_month1, retention_rate_pct(小数第1位)、cohort_month 昇順で返してください。

使用テーブル
▸ users(6行)
user_idregistered_at
12024-01-10
22024-01-15
32024-01-22
42024-02-05
52024-02-14
62024-02-20
▸ login_events(8行)
user_idevent_date
12024-01-12
22024-01-18
32024-01-25
12024-02-05
32024-02-10
42024-02-07
42024-03-02
62024-02-22
期待出力
cohort_monthcohort_sizeretained_month1retention_rate_pct
2024-01-013266.7
2024-02-013133.3
模範解答コード
WITH cohorts AS (
  -- 各ユーザーの登録月を月初日に丸めてコホートを定義
  SELECT
    user_id,
    DATE_TRUNC('month', registered_at)::date  AS cohort_month
  FROM  users
),
activity AS (
  -- ユーザーごとにアクティブだった月の一覧を重複排除して取得
  SELECT DISTINCT
    user_id,
    DATE_TRUNC('month', event_date)::date    AS active_month
  FROM  login_events
)
SELECT
  c.cohort_month,
  COUNT(DISTINCT c.user_id)  AS cohort_size,
  COUNT(DISTINCT a.user_id)  AS retained_month1,
  ROUND(
    COUNT(DISTINCT a.user_id) * 100.0 /
    COUNT(DISTINCT c.user_id), 1
  ) AS retention_rate_pct
FROM   cohorts c
LEFT JOIN activity a
  ON  a.user_id     = c.user_id
  AND a.active_month = c.cohort_month + INTERVAL '1 month'  -- 翌月のみ結合
GROUP BY c.cohort_month
ORDER BY c.cohort_month;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE cohorts            → CTE を定義
  2. CTE activity           → CTE を定義
  3. FROM cohorts c
     LEFT JOIN activity a   → 結合(左表を全行保持)
  4. GROUP BY cohort_month  → グループ化
  5. 集計関数を評価         → COUNT(DISTINCT ...)
  6. SELECT                 → 値を整形
*/
解説(テーブル変化・ポイント)
WITH cohorts AS ( SELECT user_id, DATE_TRUNC('month', registered_at)::date AS cohort_month FROM users ), activity AS ( SELECT DISTINCT user_id, DATE_TRUNC('month', event_date)::date AS active_month FROM login_events ) SELECT c.cohort_month, COUNT(DISTINCT c.user_id) AS cohort_size, COUNT(DISTINCT a.user_id) AS retained_month1, ROUND( COUNT(DISTINCT a.user_id) * 100.0 / COUNT(DISTINCT c.user_id), 1 ) AS retention_rate_pct FROM cohorts c LEFT JOIN activity a ON a.user_id = c.user_id AND a.active_month = c.cohort_month + INTERVAL '1 month' GROUP BY c.cohort_month ORDER BY c.cohort_month;
LEGEND
データ取得・読込対象
① CTE cohorts — 登録月を月初日に丸める
DATE_TRUNC('month', registered_at)::date AS cohort_monthusers テーブルの registered_at を DATE_TRUNC('month', ...) で月初日に丸めます。同月登録者が同一コホートとしてグループ化される準備です。
1 / 5
user_idregistered_at▸ cohort_month
12024-01-102024-01-01
22024-01-152024-01-01
32024-01-222024-01-01
42024-02-052024-02-01
52024-02-142024-02-01
62024-02-202024-02-01
cohorts CTE: 6行
学習ポイント
INTERVAL で月オフセットを指定する:cohort_month + INTERVAL '1 month' でリテンション期間を柔軟に変えられます。INTERVAL '2 month' で Month 2 リテンション、INTERVAL '3 month' で Month 3 リテンションと、数値を変えるだけで任意の期間のリテンションを算出できます。
activity CTE の DISTINCT がデータ品質を守る:同一ユーザーが同月に複数回ログインしても、DISTINCT で1行に集約されます。DISTINCT なしでは COUNT(DISTINCT a.user_id) でも問題ないですが、CTE レベルで重複除去しておくと結合効率が上がり、誤ったデータ解釈も防げます
コホートリテンションテーブルへの拡張:Month 1〜N のリテンションを一度に出すには UNNEST(ARRAY[1,2,3]) AS offset と組み合わせ、a.active_month = cohort_month + (offset || ' month')::INTERVAL とすることで全期間分のリテンションを一クエリで生成できます。
アンチパターン
WHERE 句に active_month 条件を書く:WHERE a.active_month = cohort_month + INTERVAL '1 month' と書くと LEFT JOIN が INNER JOIN 化し、翌月に未ログインのユーザーが消えます。これにより cohort_size が翌月アクティブ数と等しくなり、リテンション率が常に100%になるバグが発生します。
登録月の翌月だけでなく「翌月以降すべて」を結合する:ON 句を a.active_month >= cohort_month + INTERVAL '1 month' にすると、翌々月以降のアクティビティも結合されユーザーが重複集計されます。Month 1 リテンションには = ではなく = で厳密に翌月のみを指定してください。
実務コラム:リテンション率の解釈とベンチマーク
SaaS プロダクトでは Month 1 リテンションが 40% 以上であれば優良とされます。コホート別のリテンション曲線を描くと、登録チャネルやプランによって「どのコホートが定着しやすいか」が一目瞭然になります。リテンション率が低下している月のコホートは、機能リリースや価格変更など外部要因の影響を受けた可能性があり、施策タイムラインとの照合が改善の第一歩です。
QUESTION 3

ファネル分析 — FILTER(WHERE ...) でステップ別コンバージョン率を算出する

FILTER WHERECOUNT DISTINCTファネル分析CVR計算
前提知識

ファネル分析は、ユーザーが「閲覧→登録→購入」などの複数ステップを経てゴールに到達する過程を定量化します。PostgreSQL の FILTER (WHERE ...) 句を使うと、CASE WHEN より簡潔にステップ別の件数を集計できます。

-- FILTER 句(推奨)
COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'signup')

-- CASE WHEN(同等だが冗長)
COUNT(DISTINCT CASE WHEN event_type = 'signup' THEN user_id END)
各ステップを独立カウントする意味:ファネルでは「step3 に到達したユーザーは step1・2 も通過した」と仮定せず、各ステップに対応するイベント行を個別に COUNT します。実務では「カート追加せずに購入完了」のような skip も発生するため、ステップごとに独立した DISTINCT カウントが正確です。
問題

funnel_events テーブルから、4ステップのファネル(ページ閲覧→会員登録→カート追加→購入完了)の各ステップ到達ユーザー数と転換率を算出してください。CTE で各ステップのカウントを集約し、外側クエリで CVR を計算してください。出力列は page_view, signup, add_cart, purchase, view_to_signup_pct, signup_to_cart_pct, cart_to_purchase_pct(各%は小数第1位)。

使用テーブル
▸ funnel_events(14行)
user_idevent_typeevent_date
1page_view2024-01-10
2page_view2024-01-10
3page_view2024-01-10
4page_view2024-01-11
5page_view2024-01-11
1signup2024-01-10
2signup2024-01-11
3signup2024-01-12
4signup2024-01-13
2add_cart2024-01-12
3add_cart2024-01-13
4add_cart2024-01-13
3purchase2024-01-15
4purchase2024-01-16
期待出力
page_viewsignupadd_cartpurchaseview_to_signup_pctsignup_to_cart_pctcart_to_purchase_pct
543280.075.066.7
模範解答コード
WITH counts AS (
  -- 各ステップの到達ユーザーを1行に集約
  SELECT
    COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'page_view') AS s1,
    COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'signup')    AS s2,
    COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'add_cart')  AS s3,
    COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'purchase')  AS s4
  FROM   funnel_events
)
SELECT
  s1 AS page_view,
  s2 AS signup,
  s3 AS add_cart,
  s4 AS purchase,
  ROUND(s2 * 100.0 / s1, 1) AS view_to_signup_pct,    -- step1→2 の転換率
  ROUND(s3 * 100.0 / s2, 1) AS signup_to_cart_pct,    -- step2→3 の転換率
  ROUND(s4 * 100.0 / s3, 1) AS cart_to_purchase_pct   -- step3→4 の転換率
FROM   counts;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE counts        → CTE を定義
  2. 集計関数を評価    → COUNT(DISTINCT ...) FILTER で集約
  3. 外側クエリを評価  → 値を整形して列を選択
*/
解説(テーブル変化・ポイント)
WITH counts AS ( SELECT COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'page_view') AS s1, COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'signup') AS s2, COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'add_cart') AS s3, COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'purchase') AS s4 FROM funnel_events ) SELECT s1 AS page_view, s2 AS signup, s3 AS add_cart, s4 AS purchase, ROUND(s2 * 100.0 / s1, 1) AS view_to_signup_pct, ROUND(s3 * 100.0 / s2, 1) AS signup_to_cart_pct, ROUND(s4 * 100.0 / s3, 1) AS cart_to_purchase_pct FROM counts;
LEGEND
データ取得・読込対象
① FROM funnel_events(14行)
FROM funnel_eventsfunnel_events テーブルの14行を読み込みます。4種類の event_type がファネルの各ステップに対応します。この時点では全行が対象です。
1 / 4
user_idevent_typeevent_date
1page_view2024-01-10
2page_view2024-01-10
3page_view2024-01-10
4page_view2024-01-11
5page_view2024-01-11
1signup2024-01-10
2signup2024-01-11
3signup2024-01-12
4signup2024-01-13
2add_cart2024-01-12
3add_cart2024-01-13
4add_cart2024-01-13
3purchase2024-01-15
4purchase2024-01-16
14行読込(4 event_type × 各ステップ)
学習ポイント
FILTER (WHERE ...) は PostgreSQL 9.4以降の標準機能:CASE WHEN ... END より可読性が高く、クエリオプティマイザが個別に最適化しやすい形です。BigQuery や DuckDB でも同じ構文が使えます。MySQL(8.0未満)では使えず CASE WHEN が必要です。
ステップを「独立カウント」することで skip を正確に捉える:ユーザーが add_cart をスキップして直接 purchase した場合でも、s4 はカウントされます。「全ステップを順に通過したユーザーだけ」を厳密に追うには、各ステップをサブクエリ化して INNER JOIN で連鎖させる(ストリクトファネル)という別のアプローチが必要です。
Overall CVR との使い分け:今回は step 間の転換率(step CVR)を計算しました。「step1 に対する最終到達率」(overall CVR)は ROUND(s4 * 100.0 / s1, 1) です。step CVR は「どのステップで脱落が多いか」の特定に、overall CVR は「広告→購入の総合効率」の評価に使います。
アンチパターン
COUNT(*) FILTER でユーザーを重複カウントする:COUNT(*) FILTER (WHERE event_type = 'signup') は同一ユーザーが複数回 signup イベントを持つ場合に重複カウントします。COUNT(DISTINCT user_id) を使うことで、ユニークユーザー単位のファネルになります。
ゼロ除算の未対策:上位ステップのユーザー数が 0 になると ROUND(s2 * 100.0 / s1, 1) でゼロ除算エラーが発生します。本番では NULLIF(s1, 0) でガードするか、CASE WHEN s1 = 0 THEN NULL ELSE ROUND(...) END と書く習慣をつけてください。
実務コラム:ファネル分析の改善サイクル
ファネル分析は数字を出すことが目的ではなく、最も改善インパクトが大きいボトルネックを特定することが本来の目的です。例えば「カート追加→購入完了の CVR が 66.7%」と分かれば、決済フローの UX 改善・送料の見直し・リマインドメールなど具体的な施策につながります。A/B テストと組み合わせて施策前後のコホートのファネル CVR を比較することで、改善効果を定量的に証明できます。
QUESTION 4

アクティブユーザー分析 — DAU/MAU でスティッキネス(粘着性)を算出する

CTECOUNT DISTINCTDAU / MAUスティッキネス
前提知識

スティッキネス(Stickiness)は DAU(日次アクティブユーザー)の平均 ÷ MAU(月次アクティブユーザー)で算出され、ユーザーがどれだけ頻繁にサービスを使っているかを示す指標です。

指標定義算出方法
DAU特定日にセッションがあったユニークユーザー数COUNT(DISTINCT user_id) per date
MAUその月にセッションがあったユニークユーザー数COUNT(DISTINCT user_id) per month
StickinessDAU平均 ÷ MAU(高いほど習慣的に使われている)AVG(DAU) / MAU × 100
WITH per_day AS (                       -- ① 細かい粒度で集計する
  SELECT date_col,
         COUNT(DISTINCT id_col) AS daily_cnt
  FROM   table_name
  GROUP BY date_col
)
SELECT AVG(daily_cnt) AS avg_daily         -- ② 集計結果をさらに集計する
FROM   per_day;
2段階の集計が必要:まず「日ごとの DAU」を CTE で集計し、次に月ごとに DAU を平均するという2段階の集計が必要です。1クエリで書こうとすると集計の順序が崩れるため、CTE で段階的に計算するのが定石です。
問題

user_sessions テーブルから、月別の avg_dau・MAU・スティッキネスを算出してください。取得列は month, avg_dau, mau, stickiness_pct(各値は小数第1位)、month 昇順で返してください。

使用テーブル
▸ user_sessions(10行)
user_idsession_date
12024-01-01
22024-01-01
32024-01-01
12024-01-15
42024-01-15
22024-01-20
12024-02-01
22024-02-01
52024-02-10
12024-02-20
期待出力
monthavg_daumaustickiness_pct
2024-01-012.0450.0
2024-02-011.3344.4
模範解答コード
WITH daily AS (
  -- 日ごとの DAU(ユニークユーザー数)を集計
  SELECT
    DATE_TRUNC('month', session_date)::date  AS month,
    session_date,
    COUNT(DISTINCT user_id)               AS dau
  FROM   user_sessions
  GROUP BY month, session_date
),
monthly AS (
  -- 月ごとの MAU(月内でユニークなユーザー数)を集計
  SELECT
    DATE_TRUNC('month', session_date)::date  AS month,
    COUNT(DISTINCT user_id)               AS mau
  FROM   user_sessions
  GROUP BY month
)
SELECT
  m.month,
  ROUND(AVG(d.dau), 1)                        AS avg_dau,
  m.mau,
  ROUND(AVG(d.dau) * 100.0 / m.mau, 1)       AS stickiness_pct  -- DAU平均/MAU
FROM   monthly m
JOIN   daily   d USING (month)
GROUP BY m.month, m.mau
ORDER BY m.month;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE daily            → CTE を定義
  2. CTE monthly          → CTE を定義
  3. FROM monthly m
     JOIN daily d         → 結合(一致行のみ)
  4. GROUP BY m.month     → グループ化
  5. 集計関数を評価       → AVG(d.dau)
  6. SELECT               → 値を整形
  7. ORDER BY m.month     → 並び替えて出力
*/
解説(テーブル変化・ポイント)
WITH daily AS ( SELECT DATE_TRUNC('month', session_date)::date AS month, session_date, COUNT(DISTINCT user_id) AS dau FROM user_sessions GROUP BY month, session_date ), monthly AS ( SELECT DATE_TRUNC('month', session_date)::date AS month, COUNT(DISTINCT user_id) AS mau FROM user_sessions GROUP BY month ) SELECT m.month, ROUND(AVG(d.dau), 1) AS avg_dau, m.mau, ROUND(AVG(d.dau) * 100.0 / m.mau, 1) AS stickiness_pct FROM monthly m JOIN daily d USING (month) GROUP BY m.month, m.mau ORDER BY m.month;
LEGEND
データ取得・読込対象
① CTE daily — 日ごとのDAUを集計
GROUP BY month, session_date → COUNT(DISTINCT user_id) AS dauuser_sessions を月・日付でグループ化し、各日のユニークユーザー数(DAU)を集計します。1月01日はuser1・2・3の3人、1月15日はuser1・4の2人、1月20日はuser2の1人です。
1 / 4
monthsession_date▸ dau
2024-01-012024-01-013
2024-01-012024-01-152
2024-01-012024-01-201
2024-02-012024-02-012
2024-02-012024-02-101
2024-02-012024-02-201
daily CTE: 6行(月×日付の組み合わせ)
学習ポイント
DAU と MAU を1クエリで同時に計算できない理由:DAU は「日付単位のカウント」、MAU は「月単位のカウント」であり、集計の粒度が異なります。GROUP BY に両方の粒度を同時に指定することはできないため、CTE で段階的に集計することが必須です。
MAU の COUNT(DISTINCT) でユーザー重複を除く:monthly CTE では user_sessions を月でグループ化しますが、同じユーザーが月内に複数回セッションを持っても COUNT(DISTINCT user_id) で1人としてカウントします。MAU は「月内に1度でもアクティブなユニークユーザー数」が正しい定義です。
スティッキネス 50% の意味:Stickiness=50% は「月に平均して2日に1日利用している」という意味です。Facebook などの SNS は 50〜60%、SaaS ツールは 20〜40% が一般的な目安です。スティッキネスが下がっているコホートは、習慣化の定着前に離脱している可能性があり、プロダクト改善の優先度判断に使います。
アンチパターン
AVG(dau) を GROUP BY なしで計算する:SELECT AVG(COUNT(DISTINCT user_id)) のような入れ子集計は PostgreSQL で直接は書けません。必ず CTE や サブクエリで先に DAU を行に展開してから AVG を適用する2段階構成が必要です。
COUNT(*) で重複セッションを含める:1日に同一ユーザーが複数セッションを持つ場合、COUNT(*) はセッション数(回数)になり DAU(人数)ではありません。アクティブユーザー分析では必ず COUNT(DISTINCT user_id) を使ってください。
実務コラム:WAU とスティッキネスの多軸分析
DAU/MAU に加えて WAU(週次アクティブユーザー)も組み合わせることで、利用頻度の分布をより細かく把握できます。DAU/WAU は「週の中で何日使っているか」、WAU/MAU は「月の中で何週使っているか」を示します。プロダクトの利用パターンが「毎日使うもの(SNS・メッセンジャー)」か「週1〜2回(ニュース・レポート系)」かによって適切な指標が変わります。自社プロダクトの期待利用頻度に合わせてスティッキネスの目標値を設定してください。
QUESTION 5

チャーン分析 — LEFT JOIN IS NULL でひと月以上離脱したユーザーを特定する

LEFT JOIN IS NULLCTEチャーン分析離脱ユーザー特定
前提知識

チャーン(Churn)分析は「前月はアクティブだったが今月はアクティブでないユーザー」を特定します。SQLでは LEFT JOIN IS NULL パターン(アンチジョイン)が最も読みやすく効率的です。

手法書き方特徴
LEFT JOIN IS NULLLEFT JOIN ... WHERE b.key IS NULL最も可読性が高く最適化されやすい ★推奨
NOT EXISTSWHERE NOT EXISTS (SELECT 1 FROM ...)相関サブクエリ。大規模テーブルで有効
NOT INWHERE user_id NOT IN (SELECT ...)NULL を含む場合に全行除外バグあり ✗非推奨
SELECT a.id_col
FROM   table_a a
LEFT JOIN table_b b
  ON   b.id_col = a.id_col       -- 結合条件
WHERE  b.id_col IS NULL;         -- マッチしなかった行だけが残る
LEFT JOIN IS NULL の仕組み:LEFT JOIN は右テーブルにマッチしない行も保持し、右テーブルの列を NULL にします。WHERE 右テーブル.key IS NULL でこの「マッチしなかった行だけ」を抽出します。これは「前月アクティブ かつ 翌月未アクティブ」=チャーンユーザーを特定する定番パターンです。
問題

login_events テーブルから、2024年1月にアクティブだったユーザーのうち、2024年2月にログインしなかった(チャーンした)ユーザーを特定してください。取得列は user_id, active_month, is_churned、user_id 昇順で返してください。

使用テーブル
▸ login_events(6行)
user_idevent_date
12024-01-12
22024-01-18
32024-01-25
12024-02-05
32024-02-10
42024-02-08
期待出力
user_idactive_monthis_churned
12024-01false
22024-01true
32024-01false
模範解答コード
WITH jan_active AS (
  -- 2024年1月にアクティブだったユーザーを抽出(重複排除)
  SELECT DISTINCT user_id
  FROM   login_events
  WHERE  DATE_TRUNC('month', event_date) = '2024-01-01'
),
feb_active AS (
  -- 2024年2月にアクティブだったユーザーを抽出(重複排除)
  SELECT DISTINCT user_id
  FROM   login_events
  WHERE  DATE_TRUNC('month', event_date) = '2024-02-01'
)
SELECT
  j.user_id,
  '2024-01'                                             AS active_month,
  CASE WHEN f.user_id IS NULL THEN true ELSE false END  AS is_churned  -- NULL=翌月未ログイン=チャーン
FROM   jan_active  j
LEFT JOIN feb_active f USING (user_id)   -- 2月にいないユーザーは f.user_id = NULL
ORDER BY j.user_id;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE jan_active        → CTE を定義
  2. CTE feb_active        → CTE を定義
  3. FROM jan_active j
     LEFT JOIN feb_active  → 結合(左表を全行保持)
  4. SELECT                → 列を評価(CASE で判定)
  5. ORDER BY j.user_id    → 並び替えて出力
*/
解説(テーブル変化・ポイント)
WITH jan_active AS ( SELECT DISTINCT user_id FROM login_events WHERE DATE_TRUNC('month', event_date) = '2024-01-01' ), feb_active AS ( SELECT DISTINCT user_id FROM login_events WHERE DATE_TRUNC('month', event_date) = '2024-02-01' ) SELECT j.user_id, '2024-01' AS active_month, CASE WHEN f.user_id IS NULL THEN true ELSE false END AS is_churned FROM jan_active j LEFT JOIN feb_active f USING (user_id) ORDER BY j.user_id;
LEGEND
データ取得・読込対象
✓ 通過
✗ 除外
① CTE jan_active — 1月アクティブユーザーを抽出
WHERE DATE_TRUNC('month', event_date) = '2024-01-01'login_events から 2024年1月のイベント行を WHERE で絞り込み、DISTINCT でユーザーを重複排除します。1月にログインした user1・2・3 の3人が抽出されます。
1 / 4
user_idevent_date1月フィルタ?
12024-01-12✓ → jan_active
22024-01-18✓ → jan_active
32024-01-25✓ → jan_active
12024-02-05✗ 対象外
32024-02-10✗ 対象外
42024-02-08✗ 対象外
jan_active CTE: 3行 {user1, user2, user3}
学習ポイント
LEFT JOIN IS NULL がアンチジョインの定番パターン:「Aに存在してBに存在しないレコード」を求める処理をアンチジョイン(Anti Join)と呼びます。LEFT JOIN + WHERE b.key IS NULL はオプティマイザが Hash Anti Join や Merge Anti Join に書き換えやすく、パフォーマンスも良好です。
NOT IN に NULL が混入すると全行が消える:WHERE user_id NOT IN (SELECT user_id FROM feb_active) と書くと、feb_active の user_id に NULL が1行でも含まれると WHERE 全体が false になり0行が返るバグが発生します。NOT IN より LEFT JOIN IS NULL か NOT EXISTS を使うのが安全です。
チャーン率と is_churned の集約:このクエリを外側でラップして ROUND(COUNT(*) FILTER (WHERE is_churned) * 100.0 / COUNT(*), 1) とすれば「1月→2月のチャーン率」を1つの数値で算出できます。今回のケースでは 1/3 ≈ 33.3% がチャーン率です。
アンチパターン
NOT IN に NULL が混入する問題:WHERE user_id NOT IN (SELECT user_id FROM feb_active WHERE ...) のサブクエリが NULL を返すと、NOT INuser_id != NULL を評価し全行が UNKNOWN になります。結果として0行が返るという致命的なバグです。必ず LEFT JOIN IS NULLNOT EXISTS を使ってください。
INNER JOIN でチャーンを判定しようとする:INNER JOIN でどちらにも存在するユーザーを求めた後に「残り = チャーン」と考えるのは二段階になり冗長です。LEFT JOIN IS NULL は1クエリで完結し、NULL の意味が「存在しない=チャーン」と明確です。
実務コラム:チャーン予測への応用
チャーン分析の目的は「離脱後の検知」だけでなく、離脱前の予防にあります。チャーンしたユーザーの行動パターン(直近のログイン頻度低下、特定機能の利用停止)を特徴量として機械学習モデルに入力することで「チャーン予測スコア」が算出できます。SQL では LEAD(active_month) OVER (PARTITION BY user_id ORDER BY active_month) で「次のアクティブ月が翌月ではない行」を一般化すると、任意の期間・任意のコホートのチャーンを動的に検出できます。