ウィンドウ関数 ROW_NUMBER() — ユーザーごとの初回購入レコードを抽出する
ウィンドウ関数は GROUP BY のように行を集約せず、各行に「グループ内での順位や集計値」を追加します。ROW_NUMBER() はパーティション内で ORDER BY の順に 1 から連番を振ります。
ROW_NUMBER() OVER ( PARTITION BY user_id -- ユーザーごとに独立して番号を振る ORDER BY purchased_at ASC -- 古い順で連番付け(rn=1 が初回) )
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 昇順で返してください。
| user_id | item_id | purchased_at |
|---|---|---|
| 1 | A001 | 2024-01-05 |
| 2 | B001 | 2024-01-10 |
| 1 | C002 | 2024-01-15 |
| 3 | F002 | 2024-01-18 |
| 3 | D001 | 2024-01-20 |
| 2 | E003 | 2024-02-01 |
| 1 | G004 | 2024-02-10 |
| user_id | first_item | first_purchase_date |
|---|---|---|
| 1 | A001 | 2024-01-05 |
| 2 | B001 | 2024-01-10 |
| 3 | F002 | 2024-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 → 並び替えて出力 */
LEGEND
① FROM purchase_events(7行)
FROM purchase_eventspurchase_events テーブルの7行を読み込みます。ウィンドウ関数はここから始まり、GROUP BY と違い行を集約しません。青=user1(3行)、グレー=user2(2行)、オレンジ=user3(2行)が混在しています。| user_id | item_id | purchased_at |
|---|---|---|
| 1 | A001 | 2024-01-05 |
| 2 | B001 | 2024-01-10 |
| 1 | C002 | 2024-01-15 |
| 3 | F002 | 2024-01-18 |
| 3 | D001 | 2024-01-20 |
| 2 | E003 | 2024-02-01 |
| 1 | G004 | 2024-02-10 |
ROW_NUMBER() OVER (...) は集約せず全行を維持したまま各行に rn 列を追加します。絞り込み(WHERE rn=1)は CTE の外側クエリで行います。この「CTE で付与 → 外側で絞り込み」パターンは実務で非常に頻出です。ORDER BY purchased_at DESC にすれば 最新の購入が rn=1 になります。ユーザーの直近セッション・最後のログイン・最新注文ステータスなど行動分析の多くの場面でこのパターンが登場します。RANK() = 1 でフィルタすると複数行が返ることがあります。「必ず1行だけ取り出す」なら ROW_NUMBER、「タイを全部返したい」なら RANK と使い分けてください。SELECT user_id, MIN(purchased_at) FROM ... GROUP BY user_id では最初の日時は取れますが、そのときの item_id が取れません。item_id を取るためにさらに自己結合が必要になり、クエリが複雑になります。ROW_NUMBER パターンはそれを1クエリで解決します。SELECT ... ROW_NUMBER() AS rn FROM t WHERE rn = 1 とは書けません(rn は WHERE の時点でまだ存在しない)。必ず CTE かサブクエリで先に rn を生成してから外側の WHERE で絞り込んでください。ウィンドウ関数 NTILE() — 購買頻度でユーザーを4段階にスコアリングする
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 = 低スコア
purchases テーブルから、ユーザーごとの購買回数を集計し NTILE(4) で4段階の頻度スコア(freq_score)を付与してください。さらに freq_score=4 のユーザーを is_high_value = true とフラグを立て、user_id 昇順で返してください。出力列は user_id, purchase_count, freq_score, is_high_value。
| user_id | order_id | ordered_at |
|---|---|---|
| 1 | 101 | 2024-01-05 |
| 1 | 102 | 2024-01-20 |
| 1 | 103 | 2024-02-10 |
| 2 | 104 | 2024-01-08 |
| 2 | 105 | 2024-02-05 |
| 3 | 106 | 2024-01-12 |
| 3 | 107 | 2024-01-18 |
| 3 | 108 | 2024-01-24 |
| 3 | 109 | 2024-02-02 |
| 3 | 110 | 2024-02-15 |
| 4 | 111 | 2024-01-30 |
| 5 | 112 | 2024-01-09 |
| 5 | 113 | 2024-01-16 |
| 5 | 114 | 2024-02-08 |
| 5 | 115 | 2024-02-20 |
| user_id | purchase_count | freq_score | is_high_value |
|---|---|---|---|
| 1 | 3 | 2 | false |
| 2 | 2 | 1 | false |
| 3 | 5 | 4 | true |
| 4 | 1 | 1 | false |
| 5 | 4 | 3 | false |
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 → 並び替えて出力 */
LEGEND
① CTE purchase_counts — 購買回数を集計
GROUP BY user_id → COUNT(*) AS purchase_countpurchases テーブルの15行を user_id でグループ化し、各ユーザーの購買回数を集計します。この5行が NTILE の入力になります。| user_id | ► purchase_count |
|---|---|
| 1 | 3 |
| 2 | 2 |
| 3 | 5 |
| 4 | 1 |
| 5 | 4 |
CASE WHEN count >= 5 THEN 4)は期間やデータが変わると意味を失います。NTILE は「上位N%」という相対的な位置でスコアを決めるため、データが変わっても常に n 分の 1 ずつのユーザーが各バケツに入ります。NTILE ORDER BY days_since_last ASC)と Monetary(購買金額合計 → NTILE ORDER BY total_amount ASC)のスコアを同様のパターンで算出し、3スコアを掛け合わせることで RFM 総合スコアが得られます。ORDER BY purchase_count ASC では小さいほど小さいバケツに入るため tile4=高頻度です。ORDER BY purchase_count DESC にすると tile4=低頻度になり、is_high_value の判定が真逆になります。「ORDER BY ASC + tile4 = 高」か「ORDER BY DESC + tile1 = 高」を明示的にコメントで記録してください。NTILE(4) OVER (PARTITION BY segment ORDER BY ...) とするとセグメント内での相対順位になります。全体のランキングが必要なら PARTITION BY を省略してください。ウィンドウ関数 LAG() — ステップ間の経過日数を計算し時間制約付きコンバージョンを判定する
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(日数)が返ります。timestamp - timestamp は interval 型なので注意してください。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 昇順)。
| user_id | step | stepped_at |
|---|---|---|
| 1 | page_view | 2024-01-01 |
| 1 | signup | 2024-01-03 |
| 2 | page_view | 2024-01-05 |
| 2 | signup | 2024-01-07 |
| 3 | page_view | 2024-01-10 |
| 3 | signup | 2024-01-18 |
| 4 | page_view | 2024-01-15 |
| 4 | signup | 2024-01-16 |
| user_id | stepped_at | prev_stepped_at | days_from_prev | within_7days |
|---|---|---|---|---|
| 1 | 2024-01-03 | 2024-01-01 | 2 | true |
| 2 | 2024-01-07 | 2024-01-05 | 2 | true |
| 3 | 2024-01-18 | 2024-01-10 | 8 | false |
| 4 | 2024-01-16 | 2024-01-15 | 1 | true |
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 → 並び替えて出力 */
LEGEND
① FROM step_events(8行)
FROM step_eventsstep_events テーブルの8行を読み込みます。各ユーザーに page_view と signup の2行があります。LAG() は GROUP BY をせず全8行を維持したまま評価されます。| user_id | step | stepped_at |
|---|---|---|
| 1 | page_view | 2024-01-01 |
| 1 | signup | 2024-01-03 |
| 2 | page_view | 2024-01-05 |
| 2 | signup | 2024-01-07 |
| 3 | page_view | 2024-01-10 |
| 3 | signup | 2024-01-18 |
| 4 | page_view | 2024-01-15 |
| 4 | signup | 2024-01-16 |
LAG(col) は LAG(col, 1) の省略形で1行前を取得します。LAG(col, 2) で2行前、LAG(col, 1, stepped_at) のように第3引数でNULLの代わりのデフォルト値を指定できます。最初の行でもNULLにしたくない場合に便利です。stepped_at - prev_stepped_at は integer(日数)を直接返します。timestamp 型だと interval 型になるため、EXTRACT(epoch FROM (ts1 - ts2)) / 86400 など変換が必要です。型を把握して使い分けてください。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行だけを参照するため安全かつ高速です。COALESCE(prev_stepped_at, stepped_at) でガードしてください。UNNEST(ARRAY[]) + CROSS JOIN — 複数月リテンションマトリクスを1クエリで生成する
基礎編では 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月
users と login_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 昇順)。
| user_id | registered_at |
|---|---|
| 1 | 2024-01-10 |
| 2 | 2024-01-15 |
| 3 | 2024-01-22 |
| 4 | 2024-02-05 |
| 5 | 2024-02-14 |
| 6 | 2024-02-20 |
| user_id | event_date |
|---|---|
| 1 | 2024-01-12 |
| 2 | 2024-01-18 |
| 3 | 2024-01-25 |
| 1 | 2024-02-05 |
| 3 | 2024-02-10 |
| 4 | 2024-02-07 |
| 5 | 2024-02-14 |
| 6 | 2024-02-22 |
| 1 | 2024-03-05 |
| 4 | 2024-03-08 |
| 6 | 2024-03-15 |
| 4 | 2024-04-03 |
| cohort_month | offset_month | cohort_size | retained | retention_pct |
|---|---|---|---|---|
| 2024-01-01 | 1 | 3 | 2 | 66.7 |
| 2024-01-01 | 2 | 3 | 1 | 33.3 |
| 2024-01-01 | 3 | 3 | 0 | 0.0 |
| 2024-02-01 | 1 | 3 | 2 | 66.7 |
| 2024-02-01 | 2 | 3 | 1 | 33.3 |
| 2024-02-01 | 3 | 3 | 0 | 0.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 → 並び替えて出力 */
LEGEND
① CTE cohorts — 登録月コホートを定義
DATE_TRUNC('month', registered_at)::date AS cohort_monthusers テーブルから各ユーザーの登録月(月初日)を算出します。1月登録の user1・2・3 と、2月登録の user4・5・6 の2コホート(計6ユーザー)が形成されます。| user_id | registered_at | ► cohort_month |
|---|---|---|
| 1 | 2024-01-10 | 2024-01-01 |
| 2 | 2024-01-15 | 2024-01-01 |
| 3 | 2024-01-22 | 2024-01-01 |
| 4 | 2024-02-05 | 2024-02-01 |
| 5 | 2024-02-14 | 2024-02-01 |
| 6 | 2024-02-20 | 2024-02-01 |
UNNEST(ARRAY[1,2,3]) は3行を生成します。GENERATE_SERIES より柔軟で、UNNEST(ARRAY[1,3,6,12]) のように非連続な期間オフセットも指定できます。「Month 1、3、6、12 のリテンション」を指定期間だけ計算したい実務ニーズに対応できます。(o.offset_month || ' month')::interval は整数を文字列結合して interval 型に変換します。offset=1 なら '1 month'::interval、offset=3 なら '3 month'::interval と動的に変わります。INTERVAL を文字列から生成するこの手法は様々な時間オフセット計算に応用できます。COUNT / 0 で実行時エラーになります。NULLIF(COUNT(DISTINCT c.user_id), 0) で分母が 0 の場合に NULL を返し、ROUND(NULL, 1) = NULL として安全に処理してください。再帰CTE (WITH RECURSIVE) — カレンダーを生成して DAU 欠損日を 0 で補完する
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' -- ③ 終了条件(必須) )
user_sessions テーブルから、2024-01-01 〜 2024-01-07 の日次 DAU 推移(セッションなし日は 0 で補完)を出力してください。WITH RECURSIVE で日付列(date_series)を生成し、LEFT JOIN で実データを結合、COALESCE で NULL を 0 に変換してください。出力列は dt, dau(dt 昇順)。
| user_id | session_date |
|---|---|
| 1 | 2024-01-01 |
| 2 | 2024-01-01 |
| 1 | 2024-01-03 |
| 2 | 2024-01-03 |
| 1 | 2024-01-05 |
| 3 | 2024-01-05 |
| 1 | 2024-01-07 |
| 2 | 2024-01-07 |
| dt | dau |
|---|---|
| 2024-01-01 | 2 |
| 2024-01-02 | 0 |
| 2024-01-03 | 2 |
| 2024-01-04 | 0 |
| 2024-01-05 | 2 |
| 2024-01-06 | 0 |
| 2024-01-07 | 2 |
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 → 日付昇順 */
LEGEND
① アンカー — 起点となる1行を生成
SELECT '2024-01-01'::date AS dt再帰CTE はアンカー(基底ケース)から始まります。アンカーは再帰をスタートする最初の1行です。ここでは dt=2024-01-01 という1行が生成されます。以降の再帰ステップはこの行を起点に動作します。| dt | ステータス |
|---|---|
| 2024-01-01 | ← アンカー行(初期値) |
GENERATE_SERIES('2024-01-01'::date, '2024-01-07'::date, '1 day') という専用関数があり、日付生成はこちらの方が簡潔です。再帰CTE はより汎用的で、日付以外の連続生成・ツリー展開など PostgreSQL 以外の DB(BigQuery・DuckDB)でも同じ概念が使えます。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を書くときは必ず終了条件をセットで書く習慣を持ってください。