SQL イベントモデリング — ファネル・コホート分析の基礎

基礎イベントモデリングファネル分析コホート分析累積集計ユーザーセグメントPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

ファネル分析 — CASE WHEN + MAX でステップ到達フラグを立て、NULLIF でゼロ除算を防ぐ

CASE WHENNULLIFファネル分析コンバージョン率
前提知識

イベントモデリングの最重要分析のひとつがファネル分析です。「view → click → purchase」のようなステップ到達数とその転換率を計算します。縦持ちのイベントテーブルから横持ちのフラグに変換するのが出発点です。

-- CASE WHEN で boolean フラグを作る(1=達成, 0=未達成)
MAX(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS did_view

-- NULLIF で除数ゼロのときに NULL を返す(ゼロ除算エラー回避)
ROUND(num * 100.0 / NULLIF(denom, 0), 1)
MAX(CASE WHEN) vs COUNT(*) FILTER の使い分け:MAX(CASE WHEN...THEN 1 ELSE 0 END) は「1回でもそのイベントがあれば 1、なければ 0」を返します。PostgreSQL 以外(BigQuery・MySQL・Redshift)でも動作する汎用パターンのため、実務では FILTER より使われる場面が多いです。後続の SUM() で「達成ユーザー数」が算出できます。
問題

user_events テーブルから、ファネル各ステップ(view → click → purchase)の到達ユーザー数と各ステップ間のコンバージョン率を計算してください。出力列は total_users, view_users, click_users, purchase_users, view_to_click_pct, click_to_purch_pct の1行で返してください。

使用テーブル
► user_events(9行)
user_idevent_typeevent_time
1view2024-01-10 10:00
1click2024-01-10 10:05
1purchase2024-01-10 10:30
2view2024-01-10 11:00
2click2024-01-10 11:10
3view2024-01-11 09:00
4view2024-01-11 14:00
4click2024-01-11 14:20
4purchase2024-01-11 14:50
期待出力
total_usersview_usersclick_userspurchase_usersview_to_click_pctclick_to_purch_pct
443275.066.7
模範解答コード
WITH funnel AS (
  SELECT
    user_id,
    MAX(CASE WHEN event_type = 'view'     THEN 1 ELSE 0 END) AS did_view,  -- 1度でも view したら 1
    MAX(CASE WHEN event_type = 'click'    THEN 1 ELSE 0 END) AS did_click,
    MAX(CASE WHEN event_type = 'purchase' THEN 1 ELSE 0 END) AS did_purchase
  FROM   user_events
  GROUP BY user_id
)
SELECT
  COUNT(*)                                                               AS total_users,
  SUM(did_view)                                                          AS view_users,
  SUM(did_click)                                                         AS click_users,
  SUM(did_purchase)                                                      AS purchase_users,
  ROUND(SUM(did_click)    * 100.0 / NULLIF(SUM(did_view),    0), 1)  AS view_to_click_pct,  -- view→click 通過率(%)
  ROUND(SUM(did_purchase) * 100.0 / NULLIF(SUM(did_click),   0), 1)  AS click_to_purch_pct  -- click→purchase 通過率(%)
FROM   funnel;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE funnel
  2. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH funnel AS ( SELECT user_id, MAX(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS did_view, MAX(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS did_click, MAX(CASE WHEN event_type = 'purchase' THEN 1 ELSE 0 END) AS did_purchase FROM user_events GROUP BY user_id ) SELECT COUNT(*) AS total_users, SUM(did_view) AS view_users, SUM(did_click) AS click_users, SUM(did_purchase) AS purchase_users, ROUND(SUM(did_click)*100.0/NULLIF(SUM(did_view),0),1) AS view_to_click_pct, ROUND(SUM(did_purchase)*100.0/NULLIF(SUM(did_click),0),1) AS click_to_purch_pct FROM funnel;
LEGEND
データ取得・読込対象
① FROM user_events(9行)
FROM user_eventsuser_events テーブルの9行を読み込みます。4ユーザーが view / click / purchase の各ステップに参加しています。ユーザーごとにどのステップに到達したかを集計するのが今回の目的です。
1 / 5
user_idevent_type
1view
1click
1purchase
2view
2click
3view
4view
4click
4purchase
9行読込(4ユーザー × 複数イベント)
学習ポイント
MAX(CASE WHEN) は「到達したことがあるか」の最高効率な表現:同一ユーザーが view を3回発火しても MAX(...THEN 1 ELSE 0...) は 1 を返します。SUM と組み合わせることで「そのステップに1回でも到達したユーザー数」が正確に算出できます。COUNT(DISTINCT user_id) FILTER より GROUP BY 前の1パスで完結するため高速です。
NULLIF(expr, 0) でゼロ除算エラーを防ぐ:NULLIF(x, 0) は x = 0 のとき NULL を返します。NULL ÷ 何かは NULL(エラーにならない)。view_users が 0 のとき、その下流のコンバージョン率は定義不能なので NULL を返すのが正しい挙動です。0 を返すとコンバージョン率 0% と誤読されるリスクがあります。
ファネルの「厳格順序」と「到達ベース」の違い:今回のクエリは「1回でも各イベントがあったか」を独立にチェックする到達ベースです。実務では view → click → purchase の順序通りに進んだユーザーのみをカウントする厳格ファネルが求められることもあります。その場合は CTE で ROW_NUMBER() して順序を検証するか、event_time の前後関係を JOIN で確認する拡張が必要です。
アンチパターン
GROUP BY なしで MAX(CASE WHEN) を使う:GROUP BY user_id を省略すると9行全体が1グループになり、テーブル全体で1件でも click があれば did_click = 1 が返ります。「ユーザーごとの到達フラグ」には必ず GROUP BY user_id が必要です。
ELSE 句を省略する:CASE WHEN event_type = 'view' THEN 1 END と書くと、条件不成立時に 0 ではなく NULL が返ります。SUM(NULL, NULL) は NULL を無視しますが COUNT に誤差が出ることがあるため、ELSE 0 は必ず明記してください。
実務コラム:ファネル分析から離脱ポイントの特定へ
ファネル指標が算出できたら次のステップは「どこで離脱しているか」の掘り下げです。例えば view_to_click_pct が低い場合は LP・UI の改善が有効で、click_to_purch_pct が低い場合は決済フローや価格設定に問題がある可能性があります。さらに今回のクエリに CASE WHEN signup_month = '2024-01' THEN 'Jan cohort' などのコホート軸を加えれば、コホートごとのファネル比較が可能になり、施策の効果測定に直結します。
QUESTION 7

ファネル分析(厳格順序)— MIN() と CASE WHEN で初回イベント時刻を取得し順序を検証する

MINCASE WHENファネル分析順序検証
前提知識

QUESTION 6の到達ベースファネルでは「順序」が考慮されませんでしたが、実務では「view → click → purchase」と順序通りに進んだユーザーのみを評価する厳格ファネル(Strict Funnel)が求められることがあります。

-- 各ステップの初回時刻を取得
MIN(CASE WHEN event_type = 'view' THEN event_time END) AS view_time

-- 次のステップの時刻が前ステップ以降か判定
CASE WHEN view_time <= click_time THEN 1 ELSE 0 END
時刻の前後関係による厳格判定:各ステップの初回発生時刻を抽出し、その時刻が前ステップより後かを比較します。view_time <= click_time は、どちらかが NULL の場合は UNKNOWN となるため、未到達のユーザーは自動的に除外されるという便利な性質があります。
問題

user_events テーブルから、順序通りに各ステップ(view → click → purchase)を通過したユーザー数を集計してください。出力列は total_users, view_users, click_users, purchase_users の1行で返してください。

使用テーブル
► user_events(10行)
user_idevent_typeevent_time
1view2024-01-10 10:00
1click2024-01-10 10:05
1purchase2024-01-10 10:30
2click2024-01-10 11:00
2view2024-01-10 11:10
3view2024-01-11 09:00
4view2024-01-11 14:00
4purchase2024-01-11 14:10
4click2024-01-11 14:20
5view2024-01-12 10:00
期待出力
total_usersview_usersclick_userspurchase_users
5521
模範解答コード
WITH user_times AS (
  SELECT
    user_id,
    MIN(CASE WHEN event_type = 'view'     THEN event_time END) AS view_time,  -- 最初に view した時刻
    MIN(CASE WHEN event_type = 'click'    THEN event_time END) AS click_time,
    MIN(CASE WHEN event_type = 'purchase' THEN event_time END) AS purchase_time
  FROM   user_events
  GROUP BY user_id
)
SELECT
  COUNT(*) AS total_users,
  SUM(CASE WHEN view_time IS NOT NULL THEN 1 ELSE 0 END) AS view_users,
  SUM(CASE WHEN view_time <= click_time THEN 1 ELSE 0 END) AS click_users,  -- view→click の順序を満たす
  SUM(CASE WHEN view_time <= click_time
            AND click_time <= purchase_time THEN 1 ELSE 0 END) AS purchase_users  -- view→click→purchase の順序
FROM   user_times;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE user_times
  2. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH user_times AS ( SELECT user_id, MIN(CASE WHEN event_type = 'view' THEN event_time END) AS view_time, MIN(CASE WHEN event_type = 'click' THEN event_time END) AS click_time, MIN(CASE WHEN event_type = 'purchase' THEN event_time END) AS purchase_time FROM user_events GROUP BY user_id ) SELECT COUNT(*) AS total_users, SUM(CASE WHEN view_time IS NOT NULL THEN 1 ELSE 0 END) AS view_users, SUM(CASE WHEN view_time <= click_time THEN 1 ELSE 0 END) AS click_users, SUM(CASE WHEN view_time <= click_time AND click_time <= purchase_time THEN 1 ELSE 0 END) AS purchase_users FROM user_times;
LEGEND
データ取得・読込対象
① FROM user_events(10行)
FROM user_events5ユーザーのイベント履歴です。user2やuser4はイベント順序が逆転している履歴を持っています。
1 / 5
user_idevent_typeevent_time
1view10:00
1click10:05
1purchase10:30
2click11:00
2view11:10
3view09:00
4view14:00
4purchase14:10
4click14:20
5view10:00
10行読込
学習ポイント
MIN(CASE WHEN) で特定イベントの初回時刻を抽出:フラグ(1/0)の代わりに event_time を抽出することで、単なる到達だけでなく「いつ到達したか」を保持でき、順序の検証が可能になります。
時系列の前後比較による厳格ファネル:「前のステップの時刻 <= 次のステップの時刻」のように比較することで、イベント順序が逆転しているノイズ(仕様のバグや不正な遷移など)をファネルから除外できます。
NULLとの比較に注意:view_time <= click_time は、どちらかが NULL の場合は UNKNOWN となり FALSE 扱い(0が返る)となります。そのため、未到達ユーザーや一部のイベントが欠落しているユーザーは自動的に除外されるという便利な性質があります。
アンチパターン
イベントの複数回発生を考慮しない(MAXの使用):MIN() ではなく MAX() を使うと、「最後に click した時刻」を取得してしまいます。初回は順序通りだったのに、後で再クリックしたせいで順序が逆転したと誤判定されるリスクがあります。初回通過を評価する場合は MIN() が鉄則です。
実務コラム:セッションベースの順序検証への発展
実務では、「初回到達の順序」だけでなく「同一セッション内で連続して遷移したか」が求められることもあります。その場合は Window 関数の LAG() を使って「直前のイベントが view だったか」を直接判定する方法や、MATCH_RECOGNIZE(Snowflake等でサポート)を使う方法が強力です。分析要件(初回到達で良いのか、連続した遷移のみをカウントするのか)に応じてクエリを使い分けることがデータエンジニアの腕の見せ所です。
QUESTION 8

コホート分析の初歩 — 登録月コホートごとに初月30日間のリテンション率を計算する

LEFT JOININTERVALコホート分析リテンション
前提知識

コホート分析とは、同じ時期に登録したユーザーグループ(コホート)の行動を追跡する手法です。登録月別に「登録後30日以内にアクティブだったか」を集計することで、施策改善の効果が特定コホートに出ているかを判別できます。

-- ON 句に INTERVAL 条件を追加して期間を限定する
LEFT JOIN user_events e
  ON  c.user_id      = e.user_id
  AND e.event_time  >= c.signup_date
  AND e.event_time   < c.signup_date + INTERVAL '30 days'
INTERVAL を ON 句に書く理由:WHERE 句に書くと LEFT JOIN が INNER JOIN と等価になり、イベントがないユーザーが消えてしまいます。期間限定の条件は必ず ON 句に書くことで、イベントがないユーザーも event 列が NULL のまま保持されます。これが LEFT JOIN + INTERVAL パターンの核心です。
問題

usersuser_events テーブルから、登録月コホートごとに、コホートサイズ・登録後30日以内にイベントを発生させたアクティブユーザー数・リテンション率を計算してください。出力列は cohort_month, cohort_size, active_users, retention_pct、cohort_month 昇順で返してください。

使用テーブル
► users(5行)
user_idsignup_date
12024-01-05
22024-01-15
32024-02-01
42024-02-10
52024-02-20
► user_events(5行)
user_idevent_time
12024-01-07
12024-01-20
22024-01-16
32024-02-03
42024-03-15
期待出力
cohort_monthcohort_sizeactive_usersretention_pct
2024-01-0122100.0
2024-02-013133.3
模範解答コード
WITH cohorts AS (                                              -- コホート月を定義
  SELECT
    user_id,
    signup_date,
    DATE_TRUNC('month', signup_date)::date  AS cohort_month
  FROM   users
),
activity AS (                                                -- 30日以内のアクティビティ
  SELECT
    c.user_id,
    c.cohort_month,
    COUNT(e.event_time)  AS event_count         -- 0 = 非アクティブ
  FROM       cohorts c
  LEFT JOIN  user_events e
    ON  c.user_id      = e.user_id
    AND e.event_time  >= c.signup_date
    AND e.event_time   < c.signup_date + INTERVAL '30 days'  -- ON 句に期間条件
  GROUP BY c.user_id, c.cohort_month
)
SELECT
  cohort_month,
  COUNT(*)                                                    AS cohort_size,
  COUNT(*) FILTER (WHERE event_count > 0)                   AS active_users,
  ROUND(
    COUNT(*) FILTER (WHERE event_count > 0) * 100.0
    / NULLIF(COUNT(*), 0), 1)                              AS retention_pct
FROM   activity
GROUP BY cohort_month
ORDER BY cohort_month;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE cohorts
  2. CTE activity
  3. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH cohorts AS ( SELECT user_id, signup_date, DATE_TRUNC('month', signup_date)::date AS cohort_month FROM users ), activity AS ( SELECT c.user_id, c.cohort_month, COUNT(e.event_time) AS event_count FROM cohorts c LEFT JOIN user_events e ON c.user_id = e.user_id AND e.event_time >= c.signup_date AND e.event_time < c.signup_date + INTERVAL '30 days' GROUP BY c.user_id, c.cohort_month ) SELECT cohort_month, COUNT(*) AS cohort_size, COUNT(*) FILTER(WHERE event_count > 0) AS active_users, ROUND(COUNT(*) FILTER(WHERE event_count > 0) * 100.0 / NULLIF(COUNT(*),0),1) AS retention_pct FROM activity GROUP BY cohort_month ORDER BY cohort_month;
LEGEND
データ取得・読込対象
① FROM users(5行)— コホート月を定義
DATE_TRUNC('month', signup_date) → cohort_monthusers テーブルを読み込み、DATE_TRUNC で登録月(cohort_month)を付与します。1月登録は user1・user2、2月登録は user3・user4・user5 の2コホートになります。
1 / 6
user_idsignup_datecohort_month
12024-01-052024-01-01
22024-01-152024-01-01
32024-02-012024-02-01
42024-02-102024-02-01
52024-02-202024-02-01
5行 × 3列(cohort_month が追加された)
学習ポイント
INTERVAL 条件を ON 句 vs WHERE 句に書く違い:WHERE 句に e.event_time < c.signup_date + INTERVAL '30 days' と書くと、結合後に NULL 行が除外され INNER JOIN と同義になります。「イベントがないユーザーも含めて分母に計上したい」場合は必ず ON 句に書いてください。コホートリテンションの計算では全コホートメンバーが分母に必要なため、この点は特に重要です。
COUNT(e.event_time) が NULL をスキップする挙動:LEFT JOIN の結果、イベントのないユーザーの event_time 列は NULL です。COUNT(NULL) は 0 を返します(COUNT(*) と異なり NULL を無視する)。これにより event_count = 0 がアクティビティなしの意味になり、FILTER (WHERE event_count > 0) で正確なアクティブユーザー数を数えられます。
コホートを「週単位」「四半期単位」に変えるには:DATE_TRUNC('week', signup_date)DATE_TRUNC('quarter', signup_date) に変更するだけでコホートの粒度が変わります。分析したい施策の影響期間に合わせて粒度を選択するのが実務の鉄則です。
アンチパターン
INTERVAL 条件を WHERE 句に書くと非アクティブユーザーが消える:期間条件を WHERE に移すと、イベントが1件もないユーザー(user4・user5)が結果から消えます。コホートサイズが正しく2→2・3→1にならず、リテンション計算に致命的な誤差をもたらします。
COUNT(*) を COUNT(e.event_time) の代わりに使う:CTE activity 内で COUNT(*) を使うと、LEFT JOIN で NULL になった行も 1 としてカウントされます。event_count が 0 ではなく 1 になり、全ユーザーがアクティブと誤判定されます。NULL 列を安全にカウントするには COUNT(non_null_column) の形式を使ってください。
実務コラム:コホートリテンション行列の見方
コホート分析を複数期間(登録後7日・30日・90日)で繰り返すと、登録月×観測期間のリテンション行列(Retention Matrix)が完成します。各行が1コホート、各列が経過期間を表し、左上から右下にかけてリテンション率がどう下がるかを一目で把握できます。ある特定のコホートだけも30日リテンションが高い場合、その時期に行った施策(オンボーディング改善・メール配信など)が効いている可能性が高く、施策の有効性検証に直結します。
QUESTION 9

ユーザーセグメント分類 — CASE WHEN でエンゲージメントレベルを判定し、セグメントごとの平均を算出する

CASE WHENAVGユーザーセグメントエンゲージメント
前提知識

イベントモデリングでは、ユーザーを行動頻度で分類するエンゲージメントセグメントが基本分析のひとつです。CTE でユーザーごとのイベント数を集計し、外側の CASE WHEN でセグメントに分類する2ステップ構成が定石です。

CASE
  WHEN purchase_count >= 10 THEN 'frequent'
  WHEN purchase_count >= 4  THEN 'regular'
  ELSE                              'occasional'
END
-- WHEN 句は上から順に評価され、最初に真になった THEN が採用される
CASE WHEN の評価順序(ショートサーキット):CASE WHEN は上から順に評価され、最初に条件が真になった時点で終了します。そのため後続条件では、先に判定済みの上限を重ねて書く必要がありません。条件を厳しい方から順に並べることで重複条件の記述を省略できます
問題

user_events テーブルから、ユーザーごとのイベント発生数を集計し、power(5回以上)・casual(2〜4回)・light(1回)の3セグメントに分類してください。出力列は segment, user_count, avg_events(avg_events は小数第1位まで)で、user_count の多い順・segment 名昇順で返してください。

使用テーブル
► user_events(11行)
user_idevent_type
1view
1click
1purchase
1view
1click
2view
2click
2purchase
3view
3click
4view
期待出力
segmentuser_countavg_events
casual22.5
light11.0
power15.0
模範解答コード
WITH event_counts AS (                   -- ユーザーごとのイベント数
  SELECT
    user_id,
    COUNT(*) AS event_count
  FROM   user_events
  GROUP BY user_id
),
segmented AS (                         -- CASE WHEN でセグメント列を追加
  SELECT
    user_id,
    event_count,
    CASE
      WHEN event_count >= 5 THEN 'power'   -- 最初に評価(5以上)
      WHEN event_count >= 2 THEN 'casual'  -- 次に評価(2以上かつ5未満)
      ELSE                       'light'   -- それ以外(1回)
    END AS segment
  FROM   event_counts
)
SELECT
  segment,
  COUNT(*)                   AS user_count,
  ROUND(AVG(event_count), 1) AS avg_events  -- セグメント内の平均イベント数
FROM   segmented
GROUP BY segment
ORDER BY user_count DESC, segment;   -- user_count 降順・同順は segment 昇順

/*
  実行順序(SQLの論理的な評価順):
  1. CTE event_counts
  2. CTE segmented
  3. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH event_counts AS ( SELECT user_id, COUNT(*) AS event_count FROM user_events GROUP BY user_id ), segmented AS ( SELECT user_id, event_count, CASE WHEN event_count >= 5 THEN 'power' WHEN event_count >= 2 THEN 'casual' ELSE 'light' END AS segment FROM event_counts ) SELECT segment, COUNT(*) AS user_count, ROUND(AVG(event_count), 1) AS avg_events FROM segmented GROUP BY segment ORDER BY user_count DESC, segment;
LEGEND
データ取得・読込対象
① FROM user_events(11行)
FROM user_eventsuser_events テーブルの11行を読み込みます。user1 が5件と最多で power 候補、user2 が3件・user3 が2件(casual 候補)、user4 が1件(light 候補)です。
1 / 4
user_idevent_type
1view
1click
1purchase
1view
1click
2view
2click
2purchase
3view
3click
4view
11行読込(4ユーザー)
学習ポイント
CASE WHEN のショートサーキット評価と境界値の設計:WHEN は上から評価され、最初に真になった時点で終了します。そのため WHEN event_count >= 2 THEN 'casual' は「2以上かつ5未満」を明示しなくても正確に動作します。境界値は「幅が広い範囲」→「狭い範囲」の順に並べるのが読みやすく誤りも少ないパターンです。
AVG と ROUND の組み合わせ:ROUND(AVG(event_count), 1) で小数第1位まで丸めます。PostgreSQL では AVG の返り値は NUMERIC(可変精度)なので ROUND が適用できます。整数カラムの AVG は自動的に小数点を含む NUMERIC になるため、整数で割り切れなくても正確な平均が得られます。
NTILE() による等分割セグメント:今回はビジネスロジックに基づく閾値でセグメントを決めましたが、NTILE(3) OVER (ORDER BY event_count DESC) を使えば上位33%・中位33%・下位33%のようにデータ分布を等分割するセグメントが作れます。閾値を事前に決められない場合やユーザー数を均等にしたい場合に有効です。
アンチパターン
CASE WHEN の順序を逆にする:WHEN event_count >= 2 THEN 'casual' を先に書くと、event_count = 5 のユーザーも 'casual' に分類されてしまいます。CASE WHEN の評価順序を意識せずに書くと、意図しないセグメントが発生します。最も限定的な条件(>=5)を先に書いてください。
GROUP BY の集計なしに CASE WHEN でセグメントを作ろうとする:CTE event_counts を省略して直接 user_events に CASE WHEN を適用すると、ユーザーごとの件数を集計しないまま各行にセグメントが付きます。1行 = 1イベントなので、全行が 'light'(1件)になります。行動頻度に基づくセグメントは必ず GROUP BY 後の集計値を使うことが前提です。
実務コラム:RFM 分析への発展
今回は Frequency(頻度)のみでセグメントを作りましたが、実務ではRFM 分析(Recency: 最終購買からの経過日数・Frequency: 購買回数・Monetary: 購買金額)の3軸を組み合わせます。3つの CASE WHEN をそれぞれ CTE で計算し、最後にスコアを合算してセグメントを決定するパターンは、今回の延長線上に直接あります。RFM の Recency を求めるには Q10 で扱う「最終イベント日の算出」が必要になるため、RFM と最終イベント日算出を連携して理解すると効果的です。
QUESTION 10

休眠ユーザー検出 — LEFT JOIN + HAVING で最終アクティビティ日を判定し、一定期間非アクティブなユーザーを特定する

LEFT JOINHAVING休眠ユーザーチャーン検出
前提知識

イベントモデリングでの重要課題のひとつが休眠ユーザー(Dormant User)検出です。「最終イベントが N 日以上前、または一度もイベントがないユーザー」を抽出するには、LEFT JOIN + GROUP BY + HAVING のパターンが基本です。

FROM       users u
LEFT JOIN  user_events e ON u.user_id = e.user_id
GROUP BY   u.user_id, u.signup_date
HAVING     MAX(e.event_time) < '2024-01-25'   -- 基準日より一定日数以上古い
        OR MAX(e.event_time) IS NULL           -- 一度もイベントなし
WHERE と HAVING の使い分け:WHERE はグループ化の前に行を絞り込みます。HAVING はグループ化・集計の後に条件を適用します。MAX() のような集計関数の結果に条件を適用するには必ず HAVING を使います。WHERE MAX(e.event_time) < ... と書くとエラーになります。また NULLS FIRST を ORDER BY に付けることで、最終イベントがないユーザーを先頭に出力できます。
問題

usersuser_events テーブルから、最終イベントが 2024-01-25 より前(21日以上前の基準日: 2024-02-15)、またはイベントが一度もないユーザーを、user_id, signup_date, last_event_time で抽出してください。last_event_time の NULLS FIRST 昇順、同順は user_id 昇順で返してください。

使用テーブル
► users(5行)
user_idsignup_date
12024-01-01
22024-01-05
32024-01-10
42024-01-20
52024-02-01
► user_events(6行)
user_idevent_time
12024-01-10 10:00
12024-01-20 14:00
22024-01-10 09:00
32024-02-01 11:00
32024-02-10 15:00
42024-01-22 08:00
期待出力
user_idsignup_datelast_event_time
52024-02-01NULL
22024-01-052024-01-10 09:00
12024-01-012024-01-20 14:00
42024-01-202024-01-22 08:00
模範解答コード
SELECT
  u.user_id,
  u.signup_date,
  MAX(e.event_time) AS last_event_time       -- 最終イベント日時(イベントなしの場合 NULL)
FROM       users u
LEFT JOIN  user_events e ON u.user_id = e.user_id  -- イベントなしユーザーも保持
GROUP BY   u.user_id, u.signup_date
HAVING     MAX(e.event_time) < '2024-01-25'::date  -- 最終イベントが21日以上前
        OR MAX(e.event_time) IS NULL               -- 一度もイベントなし
ORDER BY   last_event_time ASC NULLS FIRST,        -- NULL(イベントなし)を先頭に
           u.user_id;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM users LEFT JOIN user_events          → 全ユーザーを保持
  2. GROUP BY u.user_id, u.signup_date         → 集約し MAX(event_time) を取得
  3. HAVING ...                                → 最終イベントが基準日前 or NULL を抽出
  4. ORDER BY last_event_time ASC NULLS FIRST, u.user_id  → 並び替えて出力
  */
解説(テーブル変化・ポイント)
SELECT u.user_id, u.signup_date, MAX(e.event_time) AS last_event_time FROM users u LEFT JOIN user_events e ON u.user_id = e.user_id GROUP BY u.user_id, u.signup_date HAVING MAX(e.event_time) < '2024-01-25'::date OR MAX(e.event_time) IS NULL ORDER BY last_event_time ASC NULLS FIRST, u.user_id;
LEGEND
データ取得・読込対象
① FROM users(5行)
users起点となる users テーブルです。この5人のユーザー全員を対象に休眠判定を行います。
1 / 6
user_idsignup_date
12024-01-01
22024-01-05
32024-01-10
42024-01-20
52024-02-01
5行
学習ポイント
WHERE と HAVING の本質的な違い:WHERE はグループ化前に各行をフィルタします。HAVING はグループ化・集計後にグループ単位でフィルタします。MAX()・COUNT()・SUM() などの集計関数の値に条件を付けるには必ず HAVING を使います。WHERE に集計関数を書くと SQL 構文エラーになります。
NULLS FIRST / NULLS LAST による NULL の位置制御:PostgreSQL のデフォルトは、ORDER BY ASC 時には NULLS LAST(NULL を最後)、DESC 時には NULLS FIRST です。一度もイベントがないユーザー(染みのアクティビ期間がない最高リスクユーザー)を先頭に出せる NULLS FIRST の指定は休眠ユーザー向けメール配信の優先度付けに直結します。
HAVING で NULL を正しく扱う:NULL は全ての比較演算子(<、>、=)で UNKNOWN を返すため、MAX(e.event_time) < '2024-01-25' のみでは NULL 行(イベントなしユーザー)が HAVING を通過しません。OR MAX(e.event_time) IS NULL を併記することでイベントなしユーザーを正しく捕捉できます。
アンチパターン
WHERE に集計関数を書く:WHERE MAX(e.event_time) < '2024-01-25' と書くと SQL 構文エラーになります。集計関数は WHERE 段階ではまだ計算されておらず、参照不可能です。集計結果に条件をかけるには必ず HAVING を使ってください。
HAVING に OR IS NULL を書かない:HAVING MAX(e.event_time) < '2024-01-25' だけだと、NULL(イベントなしユーザー)は UNKNOWN として判定され、HAVING を通過しません。履歴がない想定リスクの高いユーザーが漏れるため、OR IS NULL は必須です。
実務コラム:休眠ユーザー再エンゲージ施策への応用
休眠ユーザーリストが戻れば、次のステップは再エンゲージ施策(Win-back Campaign)です。例えば signup_date が直近のユーザーと 1年以上前のユーザーではアプローチを変えるべきです。CASE WHEN で休眠期間幅(短期・中期・長期)を分類することで、初めてカスタマーサクセス指標に貢献できます。期間集計・休眠判定・分類を組み合わせると、実務レベルのイベント分析パイプラインを構築できます。