SQL 行動分析 — ウィンドウ関数・再帰CTE・期間分析の応用

応用行動分析ウィンドウ関数ROW_NUMBER / NTILE / LAG再帰CTE / UNNESTPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

ウィンドウ関数 ROW_NUMBER() — ユーザーごとの初回購入レコードを抽出する

ROW_NUMBERPARTITION BY購買分析初回イベント抽出
前提知識

ウィンドウ関数は GROUP BY のように行を集約せず、各行に「グループ内での順位や集計値」を追加します。ROW_NUMBER() はパーティション内で ORDER BY の順に 1 から連番を振ります。

ROW_NUMBER() OVER (
  PARTITION BY user_id            -- ユーザーごとに独立して番号を振る
  ORDER BY     purchased_at ASC  -- 古い順で連番付け(rn=1 が初回)
)
ROW_NUMBER vs RANK vs DENSE_RANK:日時が完全一致するタイがある場合、ROW_NUMBER は常に一意な番号を振ります(どちらが 1 になるかは不定)。RANK はタイに同じ番号を振り次をスキップします。「初回イベントを1行だけ取り出す」場合は ROW_NUMBER + WHERE rn = 1 が最も安全なパターンです。
問題

purchase_events テーブルから、各ユーザーの初回購入レコード(user_id, first_item, first_purchase_date)を抽出してください。CTE で各行に purchased_at の古い順で連番(rn)を付与し、外側クエリで rn=1 の行だけを絞り込んでください。user_id 昇順で返してください。

使用テーブル
► purchase_events(7行)
user_iditem_idpurchased_at
1A0012024-01-05
2B0012024-01-10
1C0022024-01-15
3F0022024-01-18
3D0012024-01-20
2E0032024-02-01
1G0042024-02-10
期待出力
user_idfirst_itemfirst_purchase_date
1A0012024-01-05
2B0012024-01-10
3F0022024-01-18
模範解答コード
WITH ranked AS (
  SELECT
    user_id,
    item_id,
    purchased_at,
    ROW_NUMBER() OVER (
      PARTITION BY user_id           -- ユーザーごとに独立した連番
      ORDER BY     purchased_at ASC  -- 古い順で rn=1 が初回
    ) AS rn
  FROM   purchase_events
)
SELECT
  user_id,
  item_id        AS first_item,
  purchased_at   AS first_purchase_date
FROM   ranked
WHERE  rn = 1                        -- ユーザーごとの最初の行だけ抽出
ORDER BY user_id;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE ranked を定義       → ウィンドウ関数を評価(行数は保持)
  2. WHERE rn = 1            → 行を絞り込む
  3. SELECT                  → 列を評価(first_item, first_purchase_date)
  4. ORDER BY user_id        → 並び替えて出力
*/
解説(テーブル変化・ポイント)
WITH ranked AS ( SELECT user_id, item_id, purchased_at, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY purchased_at ASC ) AS rn FROM purchase_events ) SELECT user_id, item_id AS first_item, purchased_at AS first_purchase_date FROM ranked WHERE rn = 1 ORDER BY user_id;
LEGEND
データ取得・読込対象
① FROM purchase_events(7行)
FROM purchase_eventspurchase_events テーブルの7行を読み込みます。ウィンドウ関数はここから始まり、GROUP BY と違い行を集約しません。青=user1(3行)、グレー=user2(2行)、オレンジ=user3(2行)が混在しています。
1 / 5
user_iditem_idpurchased_at
1A0012024-01-05
2B0012024-01-10
1C0022024-01-15
3F0022024-01-18
3D0012024-01-20
2E0032024-02-01
1G0042024-02-10
7行読込(全行を維持したままウィンドウ関数評価へ)
学習ポイント
ウィンドウ関数は「行を消さずに情報を追加」する:GROUP BY は複数行を1行に集約しますが、ROW_NUMBER() OVER (...) は集約せず全行を維持したまま各行に rn 列を追加します。絞り込み(WHERE rn=1)は CTE の外側クエリで行います。この「CTE で付与 → 外側で絞り込み」パターンは実務で非常に頻出です。
「グループごと最新 / 最古の1行」への汎用応用:ORDER BY purchased_at DESC にすれば 最新の購入が rn=1 になります。ユーザーの直近セッション・最後のログイン・最新注文ステータスなど行動分析の多くの場面でこのパターンが登場します。
ROW_NUMBER vs RANK — タイへの対応:同一 user_id で purchased_at が完全一致する行がある場合、RANK() = 1 でフィルタすると複数行が返ることがあります。「必ず1行だけ取り出す」なら ROW_NUMBER、「タイを全部返したい」なら RANK と使い分けてください。
アンチパターン
GROUP BY + MIN(purchased_at) だけでは item_id が取れない:SELECT user_id, MIN(purchased_at) FROM ... GROUP BY user_id では最初の日時は取れますが、そのときの item_id が取れません。item_id を取るためにさらに自己結合が必要になり、クエリが複雑になります。ROW_NUMBER パターンはそれを1クエリで解決します。
WHERE を CTE の内側に書いてしまう:ウィンドウ関数は SELECT 句で評価されるため、SELECT ... ROW_NUMBER() AS rn FROM t WHERE rn = 1 とは書けません(rn は WHERE の時点でまだ存在しない)。必ず CTE かサブクエリで先に rn を生成してから外側の WHERE で絞り込んでください。
実務コラム:初回購入分析のユースケース
初回購入レコードの抽出は、施策効果の因果分析において基本的な前処理です。「広告Aを見た後の初回購入 item_id の分布」「登録からの初回購入までの日数分布」「施策適用前後のコホートで初回購入 CVR がどう変わったか」など、first_item と first_purchase_date を結合キーとして使う場面は多岐にわたります。ROW_NUMBER による初回行抽出は行動分析 SQL の最重要パターンの一つです。
QUESTION 2

ウィンドウ関数 NTILE() — 購買頻度でユーザーを4段階にスコアリングする

NTILECTEユーザーセグメンテーション頻度スコア
前提知識

NTILE(n) は全行を n 個のバケツに等分割し、各行にバケツ番号(1〜n)を割り振るウィンドウ関数です。RFM 分析の Frequency(購買頻度)スコアや、ユーザーのエンゲージメントレベルの自動分類によく使われます。

NTILE(4) OVER (ORDER BY purchase_count ASC)
-- 5ユーザーを4バケツに分割: [2行, 1行, 1行, 1行]
-- 余り行は先頭バケツが受け取る(bucket1 が2ユーザー)
-- ORDER BY ASC → 小さい値が tile1 = 低スコア
NTILE の分割ルール:行数がバケツ数で割り切れない場合、余りの行数だけ先頭バケツが1行多くなります。5行を4分割すると (5÷4=1余り1) → バケツ1が2行、バケツ2〜4が各1行。ORDER BY ASC なら小さい値が低いバケツに入るため、スコア高=高頻度ユーザー(バケツ4)になります。
問題

purchases テーブルから、ユーザーごとの購買回数を集計し NTILE(4) で4段階の頻度スコア(freq_score)を付与してください。さらに freq_score=4 のユーザーを is_high_value = true とフラグを立て、user_id 昇順で返してください。出力列は user_id, purchase_count, freq_score, is_high_value

使用テーブル
► purchases(15行)
user_idorder_idordered_at
11012024-01-05
11022024-01-20
11032024-02-10
21042024-01-08
21052024-02-05
31062024-01-12
31072024-01-18
31082024-01-24
31092024-02-02
31102024-02-15
41112024-01-30
51122024-01-09
51132024-01-16
51142024-02-08
51152024-02-20
期待出力
user_idpurchase_countfreq_scoreis_high_value
132false
221false
354true
411false
543false
模範解答コード
WITH purchase_counts AS (
  -- ユーザーごとの購買回数を集計
  SELECT
    user_id,
    COUNT(*) AS purchase_count
  FROM   purchases
  GROUP BY user_id
),
scored AS (
  -- NTILE(4) で4段階スコアリング(1=低頻度, 4=高頻度)
  SELECT
    user_id,
    purchase_count,
    NTILE(4) OVER (
      ORDER BY purchase_count ASC  -- 少ない順 → tile1=低頻度
    ) AS freq_score
  FROM   purchase_counts
)
SELECT
  user_id,
  purchase_count,
  freq_score,
  (freq_score = 4) AS is_high_value  -- スコア4=最高頻度セグメント
FROM   scored
ORDER BY user_id;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE purchase_counts を定義  → グループ化・集計関数を評価
  2. CTE scored を定義           → ウィンドウ関数を評価(行数は保持)
  3. SELECT                      → 列を評価(freq_score, is_high_value)
  4. ORDER BY user_id            → 並び替えて出力
*/
解説(テーブル変化・ポイント)
WITH purchase_counts AS ( SELECT user_id, COUNT(*) AS purchase_count FROM purchases GROUP BY user_id ), scored AS ( SELECT user_id, purchase_count, NTILE(4) OVER ( ORDER BY purchase_count ASC ) AS freq_score FROM purchase_counts ) SELECT user_id, purchase_count, freq_score, (freq_score = 4) AS is_high_value FROM scored ORDER BY user_id;
LEGEND
データ取得・読込対象
① CTE purchase_counts — 購買回数を集計
GROUP BY user_id → COUNT(*) AS purchase_countpurchases テーブルの15行を user_id でグループ化し、各ユーザーの購買回数を集計します。この5行が NTILE の入力になります。
1 / 3
user_id► purchase_count
13
22
35
41
54
purchase_counts CTE: 5行
学習ポイント
NTILE はデータ分布に自動追従するスコアリング:ハードコーディングした閾値(例: CASE WHEN count >= 5 THEN 4)は期間やデータが変わると意味を失います。NTILE は「上位N%」という相対的な位置でスコアを決めるため、データが変わっても常に n 分の 1 ずつのユーザーが各バケツに入ります。
2段階 CTE で関心を分離する:purchase_counts で「集計」、scored で「スコアリング」と役割を分けることで、それぞれのロジックが独立してテスト・変更できます。scored の NTILE の数を 4→5 に変えるだけで5段階スコアリングへの変更が一瞬で完了します。
RFM 分析への拡張:Frequency(今回)に加えて、Recency(最終購入からの日数 → NTILE ORDER BY days_since_last ASC)と Monetary(購買金額合計 → NTILE ORDER BY total_amount ASC)のスコアを同様のパターンで算出し、3スコアを掛け合わせることで RFM 総合スコアが得られます。
アンチパターン
ASC/DESC の方向を誤るとスコアの意味が逆転する:ORDER BY purchase_count ASC では小さいほど小さいバケツに入るため tile4=高頻度です。ORDER BY purchase_count DESC にすると tile4=低頻度になり、is_high_value の判定が真逆になります。「ORDER BY ASC + tile4 = 高」か「ORDER BY DESC + tile1 = 高」を明示的にコメントで記録してください。
NTILE に PARTITION BY を付けすぎる:ユーザー全体でスコアを付けたい場合、NTILE(4) OVER (PARTITION BY segment ORDER BY ...) とするとセグメント内での相対順位になります。全体のランキングが必要なら PARTITION BY を省略してください。
実務コラム:Frequency スコアをキャンペーン配信に活用する
NTILE で付与した freq_score を CRM のユーザーテーブルに書き戻すことで、セグメント別メール配信が実現できます。score=4(高頻度)ユーザーには「VIP感謝クーポン」、score=1(低頻度)ユーザーには「再購入促進キャンペーン」というように施策を分岐させます。スコアは月次バッチで再計算することで常に最新の購買行動を反映できます。分類に明確な根拠があり再現可能という点で、NTILE ベースのセグメンテーションはビジネスサイドへの説明が容易です。
QUESTION 3

ウィンドウ関数 LAG() — ステップ間の経過日数を計算し時間制約付きコンバージョンを判定する

LAGPARTITION BYファネル分析時間制約CVR
前提知識

LAG(col) はウィンドウ内で1行前の値を取得します。パーティション内の最初の行は NULL になります。

LAG(stepped_at) OVER (
  PARTITION BY user_id         -- ユーザーごとに独立評価
  ORDER BY     stepped_at ASC  -- 時系列順
) AS prev_stepped_at
-- 最初の行(page_view)はNULL。次の行(signup)には page_view の日時が入る
date - date は integer(日数):PostgreSQL で date 型 - date 型 を計算すると integer(日数)が返ります。timestamp - timestampinterval 型なので注意してください。stepped_at - prev_stepped_at <= 7 のように直接比較でき、7日以内コンバージョン判定が1行で書けます。
問題

step_events テーブルから、各ユーザーの signup ステップにおける直前ステップ(page_view)からの経過日数と、7日以内コンバージョンかどうかを算出してください。LAG() で1行前の日時を取得し、WHERE で signup 行のみ抽出してください。出力列は user_id, stepped_at, prev_stepped_at, days_from_prev, within_7days(user_id 昇順)。

使用テーブル
► step_events(8行)
user_idstepstepped_at
1page_view2024-01-01
1signup2024-01-03
2page_view2024-01-05
2signup2024-01-07
3page_view2024-01-10
3signup2024-01-18
4page_view2024-01-15
4signup2024-01-16
期待出力
user_idstepped_atprev_stepped_atdays_from_prevwithin_7days
12024-01-032024-01-012true
22024-01-072024-01-052true
32024-01-182024-01-108false
42024-01-162024-01-151true
模範解答コード
WITH step_lagged AS (
  SELECT
    user_id,
    step,
    stepped_at,
    LAG(stepped_at) OVER (     -- 同一ユーザーの直前ステップ日時を取得
      PARTITION BY user_id
      ORDER BY     stepped_at ASC
    ) AS prev_stepped_at
  FROM   step_events
)
SELECT
  user_id,
  stepped_at,
  prev_stepped_at,
  (stepped_at - prev_stepped_at)       AS days_from_prev,  -- date-date → integer
  (stepped_at - prev_stepped_at) <= 7  AS within_7days     -- 7日以内の真偽値
FROM   step_lagged
WHERE  step = 'signup'                  -- signup 行のみ(page_view行を除外)
ORDER BY user_id;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE step_lagged を定義  → ウィンドウ関数を評価(行数は保持)
  2. WHERE step = 'signup'   → 行を絞り込む
  3. SELECT                  → 列を評価(days_from_prev, within_7days)
  4. ORDER BY user_id        → 並び替えて出力
*/
解説(テーブル変化・ポイント)
WITH step_lagged AS ( SELECT user_id, step, stepped_at, LAG(stepped_at) OVER ( PARTITION BY user_id ORDER BY stepped_at ASC ) AS prev_stepped_at FROM step_events ) SELECT user_id, stepped_at, prev_stepped_at, (stepped_at - prev_stepped_at) AS days_from_prev, (stepped_at - prev_stepped_at) <= 7 AS within_7days FROM step_lagged WHERE step = 'signup' ORDER BY user_id;
LEGEND
データ取得・読込対象
① FROM step_events(8行)
FROM step_eventsstep_events テーブルの8行を読み込みます。各ユーザーに page_view と signup の2行があります。LAG() は GROUP BY をせず全8行を維持したまま評価されます。
1 / 5
user_idstepstepped_at
1page_view2024-01-01
1signup2024-01-03
2page_view2024-01-05
2signup2024-01-07
3page_view2024-01-10
3signup2024-01-18
4page_view2024-01-15
4signup2024-01-16
8行読込(ユーザーごとに2ステップ)
学習ポイント
LAG(col, n, default) の3引数:LAG(col)LAG(col, 1) の省略形で1行前を取得します。LAG(col, 2) で2行前、LAG(col, 1, stepped_at) のように第3引数でNULLの代わりのデフォルト値を指定できます。最初の行でもNULLにしたくない場合に便利です。
date - date は integer、timestamp - timestamp は interval:stepped_at が date 型なら stepped_at - prev_stepped_at は integer(日数)を直接返します。timestamp 型だと interval 型になるため、EXTRACT(epoch FROM (ts1 - ts2)) / 86400 など変換が必要です。型を把握して使い分けてください。
LEAD() との使い分け:LAG は過去を参照し LEAD は未来を参照します。LEAD(stepped_at) OVER (PARTITION BY user_id ORDER BY stepped_at) で次ステップの日時を取得でき、signup 後7日以内に purchase が来るかの予測や、次のアクティブ月が翌月でない行のチャーン予兆検出など幅広く応用できます。
アンチパターン
自己結合で前の行を取得しようとする:FROM step_events e1 JOIN step_events e2 ON e1.user_id = e2.user_id AND e2.step = 'page_view' はユーザーが同じステップを複数回持つ場合に行が増殖します。LAG() はパーティション内の直前の1行だけを参照するため安全かつ高速です。
LAG の NULL 行を計算に混入させる:各パーティション最初の行(page_view)は prev_stepped_at=NULL です。NULL - date は NULL になり NULL <= 7 は NULL(false 相当)になります。WHERE step='signup' で NULL 行を除去するか COALESCE(prev_stepped_at, stepped_at) でガードしてください。
実務コラム:時間制約ファネルでエンゲージメント速度を測る
「7日以内に signup した割合」はファネルの時間的品質を示す指標です。たとえ signup 数が増えても全員が30日後に来ていたなら広告の即効性はありません。LAG を使ったステップ間時間差は、マーケティングチャネル別・デバイス別のエンゲージメント速度の比較にも使えます。セッションログに適用すると「最後のログインから現在までの日数」→チャーン予兆スコアの特徴量にも直結します。
QUESTION 4

UNNEST(ARRAY[]) + CROSS JOIN — 複数月リテンションマトリクスを1クエリで生成する

UNNESTCROSS JOINリテンション分析マルチ期間
前提知識

基礎編では INTERVAL '1 month' で翌月リテンションを1期間だけ算出しました。UNNEST(ARRAY[1,2,3]) と CROSS JOIN を組み合わせると、Month 1〜3 のリテンションをループなしに1クエリで一括生成できます。

-- UNNEST で配列を行に展開
SELECT UNNEST(ARRAY[1, 2, 3]) AS offset_month
-- → 3行: offset=1, offset=2, offset=3

-- 動的 INTERVAL 生成
cohort_month + (offset_month || ' month')::interval
-- offset=1 → cohort_month + 1ヶ月 → target月
CROSS JOIN は直積(全組み合わせ)を生成:2コホート × 3オフセット = 6グループの組み合わせが生成されます。各コホートが各期間のリテンション行を持つため、offset=3 で一致するアクティブ月がなくてもゼロ行ではなく retained=0 の行が確実に残ります。LEFT JOIN + GROUP BY が0件をカバーする構造です。
問題

userslogin_events テーブルから、コホート別 Month 1〜3 のリテンション率マトリクスを算出してください。UNNEST で offset=1,2,3 の3行を生成し、CROSS JOIN でコホート×オフセットの全組み合わせを展開、LEFT JOIN でアクティブ月を結合してください。出力列は cohort_month, offset_month, cohort_size, retained, retention_pct(cohort_month, offset_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(12行)
user_idevent_date
12024-01-12
22024-01-18
32024-01-25
12024-02-05
32024-02-10
42024-02-07
52024-02-14
62024-02-22
12024-03-05
42024-03-08
62024-03-15
42024-04-03
期待出力
cohort_monthoffset_monthcohort_sizeretainedretention_pct
2024-01-0113266.7
2024-01-0123133.3
2024-01-013300.0
2024-02-0113266.7
2024-02-0123133.3
2024-02-013300.0
模範解答コード
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
),
offsets(offset_month) AS (            -- Month 1・2・3 のオフセット値を生成
  SELECT UNNEST(ARRAY[1, 2, 3])
)
SELECT
  c.cohort_month,
  o.offset_month,
  COUNT(DISTINCT c.user_id)  AS cohort_size,
  COUNT(DISTINCT a.user_id)  AS retained,
  ROUND(
    COUNT(DISTINCT a.user_id) * 100.0 /
    NULLIF(COUNT(DISTINCT c.user_id), 0), 1  -- ゼロ除算ガード
  ) AS retention_pct
FROM       cohorts  c
CROSS JOIN offsets  o                  -- 全組み合わせ(2コホート×3オフセット)
LEFT JOIN  activity a
  ON  a.user_id     = c.user_id
  AND a.active_month = c.cohort_month + (o.offset_month || ' month')::interval
GROUP BY c.cohort_month, o.offset_month
ORDER BY c.cohort_month, o.offset_month;

/*
  実行順序(SQLの論理的な評価順):
  1. CTE cohorts を定義       → 値を整形
  2. CTE activity を定義      → 重複を除去
  3. CTE offsets を定義       → 派生テーブルを評価
  4. CROSS JOIN offsets o     → 結合(直積)
  5. LEFT JOIN activity a     → 結合(左表を全行保持)
  6. GROUP BY                 → グループ化
  7. SELECT                   → 集計関数を評価(cohort_size, retained)
  8. ROUND(...)               → 値を整形
  9. ORDER BY                 → 並び替えて出力
*/
解説(テーブル変化・ポイント)
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 ), offsets(offset_month) AS ( SELECT UNNEST(ARRAY[1, 2, 3]) ) SELECT c.cohort_month, o.offset_month, COUNT(DISTINCT c.user_id) AS cohort_size, COUNT(DISTINCT a.user_id) AS retained, ROUND(COUNT(DISTINCT a.user_id)*100.0/NULLIF(COUNT(DISTINCT c.user_id),0),1) AS retention_pct FROM cohorts c CROSS JOIN offsets o LEFT JOIN activity a ON a.user_id = c.user_id AND a.active_month = c.cohort_month + (o.offset_month || ' month')::interval GROUP BY c.cohort_month, o.offset_month ORDER BY c.cohort_month, o.offset_month;
LEGEND
データ取得・読込対象
① CTE cohorts — 登録月コホートを定義
DATE_TRUNC('month', registered_at)::date AS cohort_monthusers テーブルから各ユーザーの登録月(月初日)を算出します。1月登録の user1・2・3 と、2月登録の user4・5・6 の2コホート(計6ユーザー)が形成されます。
1 / 7
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行(2コホート)
学習ポイント
UNNEST は任意の配列を行列に変換する:UNNEST(ARRAY[1,2,3]) は3行を生成します。GENERATE_SERIES より柔軟で、UNNEST(ARRAY[1,3,6,12]) のように非連続な期間オフセットも指定できます。「Month 1、3、6、12 のリテンション」を指定期間だけ計算したい実務ニーズに対応できます。
CROSS JOIN が「0件行の消失」を防ぐ:offset=3 でアクティブユーザーが1人もいない場合、LEFT JOIN のみだと対応行がなくなりその組み合わせが結果から消えます。CROSS JOIN で先に全組み合わせを展開してから LEFT JOIN することで、retained=0 の行が確実に結果に残ります。この「先に骨格を作る」構造はリテンションマトリクス生成の定番パターンです。
動的 INTERVAL で期間を一般化する:(o.offset_month || ' month')::interval は整数を文字列結合して interval 型に変換します。offset=1 なら '1 month'::interval、offset=3 なら '3 month'::interval と動的に変わります。INTERVAL を文字列から生成するこの手法は様々な時間オフセット計算に応用できます。
アンチパターン
各月ごとに別クエリを UNION ALL するのは保守コストが高い:Month 1・2・3 を各々 SELECT + UNION ALL でまとめる方法は、期間を追加するたびにクエリを修正する必要があります。UNNEST + CROSS JOIN なら ARRAY に値を追加するだけで拡張できます。
NULLIF ガードを省略するとゼロ除算が発生する:コホートサイズが 0 のグループが混在する(例: テスト期間のデータ)と COUNT / 0 で実行時エラーになります。NULLIF(COUNT(DISTINCT c.user_id), 0) で分母が 0 の場合に NULL を返し、ROUND(NULL, 1) = NULL として安全に処理してください。
実務コラム:リテンションマトリクスのヒートマップ化
このクエリの結果を BI ツール(Looker・Tableau・Metabase)に渡すと、横軸=オフセット月・縦軸=コホート月・セル値=retention_pct のリテンションヒートマップが簡単に作れます。色が濃い(高リテンション)コホートとの比較から、「施策Xを導入した月のコホートは Month 2 以降が顕著に高い」といった施策の長期的な効果を視覚的に発見できます。UNNEST の ARRAY を変えるだけで Week 1〜12 などの週次リテンションにも対応できます。
QUESTION 5

再帰CTE (WITH RECURSIVE) — カレンダーを生成して DAU 欠損日を 0 で補完する

WITH RECURSIVECOALESCE日次DAU推移欠損日補完
前提知識

WITH RECURSIVE は自分自身を参照する CTE です。構造は「アンカー(初期行) + UNION ALL + 再帰ステップ」で成り立ちます。終了条件(WHERE)がないと無限ループになるため必ず指定します。

WITH RECURSIVE date_series AS (
  SELECT '2023-08-01'::date AS dt    -- ① アンカー(起点)
  UNION ALL
  SELECT (dt + INTERVAL '1 day')::date  -- ② 再帰ステップ
  FROM   date_series
  WHERE  dt < '2023-08-10'              -- ③ 終了条件(必須)
)
再帰CTE の動作:アンカー行を起点に、停止条件が真の間だけ次の日付を追加し続けます。終端の日付を生成した後、次の評価で条件が偽になると停止します。生成された日付列に LEFT JOIN + COALESCE で実データを結合し、欠損日を 0 で補完します。
問題

user_sessions テーブルから、2024-01-01 〜 2024-01-07 の日次 DAU 推移(セッションなし日は 0 で補完)を出力してください。WITH RECURSIVE で日付列(date_series)を生成し、LEFT JOIN で実データを結合、COALESCE で NULL を 0 に変換してください。出力列は dt, dau(dt 昇順)。

使用テーブル
► user_sessions(8行)
user_idsession_date
12024-01-01
22024-01-01
12024-01-03
22024-01-03
12024-01-05
32024-01-05
12024-01-07
22024-01-07
期待出力
dtdau
2024-01-012
2024-01-020
2024-01-032
2024-01-040
2024-01-052
2024-01-060
2024-01-072
模範解答コード
WITH RECURSIVE date_series AS (
  SELECT '2024-01-01'::date AS dt  -- アンカー: 集計開始日
  UNION ALL
  SELECT (dt + INTERVAL '1 day')::date
  FROM   date_series
  WHERE  dt < '2024-01-07'              -- 終了条件: 01-07まで
),
daily_dau AS (
  SELECT
    session_date,
    COUNT(DISTINCT user_id) AS dau
  FROM   user_sessions
  GROUP BY session_date
)
SELECT
  ds.dt,
  COALESCE(d.dau, 0) AS dau          -- NULL日は0で補完
FROM      date_series ds
LEFT JOIN daily_dau   d ON d.session_date = ds.dt
ORDER BY  ds.dt;

/*
  実行順序(SQLの論理的な評価順):
  1. WITH RECURSIVE date_series
  2. CTE daily_dau
  3. FROM date_series ds
  4. COALESCE(d.dau, 0)
  5. ORDER BY ds.dt  → 日付昇順
  */
解説(テーブル変化・ポイント)
WITH RECURSIVE date_series AS ( SELECT '2024-01-01'::date AS dt UNION ALL SELECT (dt + INTERVAL '1 day')::date FROM date_series WHERE dt < '2024-01-07' ), daily_dau AS ( SELECT session_date, COUNT(DISTINCT user_id) AS dau FROM user_sessions GROUP BY session_date ) SELECT ds.dt, COALESCE(d.dau, 0) AS dau FROM date_series ds LEFT JOIN daily_dau d ON d.session_date = ds.dt ORDER BY ds.dt;
LEGEND
データ取得・読込対象
① アンカー — 起点となる1行を生成
SELECT '2024-01-01'::date AS dt再帰CTE はアンカー(基底ケース)から始まります。アンカーは再帰をスタートする最初の1行です。ここでは dt=2024-01-01 という1行が生成されます。以降の再帰ステップはこの行を起点に動作します。
1 / 6
dtステータス
2024-01-01← アンカー行(初期値)
date_series: 1行(アンカー)
学習ポイント
再帰CTE の3要素:アンカー・再帰ステップ・終了条件:アンカーは初期行(起点)、再帰ステップは前の累積結果を使って次の行を生成する処理、終了条件は無限ループを防ぐ WHERE 句です。この構造を意識することで、日付生成・組織階層展開・連続処理など様々なパターンに応用できます。
「現在の累積結果」「今回追加」「次に生成される行」の3段階で理解する:再帰の各イテレーションで「前の UNION ALL の結果(累積)」に新しい行を UNION ALL で追加していきます。「今 dt=01-03 がある」→「01-03 < 01-07 ✓ → dt=01-04 を生成」→「01-04 が累積に追加される」という思考プロセスで追えます。ビジュアライザのステップを前後させてこの流れを体感してください。
再帰CTE vs GENERATE_SERIES:PostgreSQL には GENERATE_SERIES('2024-01-01'::date, '2024-01-07'::date, '1 day') という専用関数があり、日付生成はこちらの方が簡潔です。再帰CTE はより汎用的で、日付以外の連続生成・ツリー展開など PostgreSQL 以外の DB(BigQuery・DuckDB)でも同じ概念が使えます。
アンチパターン
LEFT JOIN を INNER JOIN にすると欠損日が消える:FROM date_series ds INNER JOIN daily_dau d ON ... にすると、セッションのない日(01-02・04・06)が結果から消えます。カレンダーを左テーブルとする LEFT JOIN のみが「全日付を維持しつつ実データを紐付ける」正しい構造です。
終了条件を忘れると無限ループ:WHERE dt < '2024-01-07' を省略すると再帰が止まらず、PostgreSQL のデフォルト上限(max_recursion_depth=100)に達してエラーになります。再帰CTEを書くときは必ず終了条件をセットで書く習慣を持ってください。
実務コラム:カレンダーテーブルとしての活用
本番環境では date_series を毎回再帰で生成するより、永続カレンダーテーブル(dim_date)を事前に作成しておく方が効率的です。年月日・曜日・祝日フラグ・営業日フラグ等を持つ dim_date に LEFT JOIN するだけで、DAU 補完・週次集計・営業日ベースの SLA 計算などが容易になります。再帰CTE はその dim_date を生成する一手段として、または ETL の一時テーブル作成に使われます。「カレンダーを骨格にして実データを LEFT JOIN する」という発想はデータウェアハウス設計の基本原則の一つです。