SQL CTE・WITH句 — 事前集計・ウィンドウ関数連携の基礎

基礎CTE (WITH句)SQLリファクタリング可読性PostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

事前集計でFan-out防止 — 結合前に集計して行増殖を防ぐ

事前集計Fan-out回避LEFT JOINアンチパターン回避
前提知識

1つの親テーブル(例:users)に対して、2つの子テーブル(例:orders と reviews)をそのまま LEFT JOIN すると、行が掛け算で増殖(Fan-out)してしまい、集計結果が異常な値になります。

これを防ぐための鉄則が、「CTEを使って子テーブルをそれぞれ 1対1 の形(user_id ごとの1行)に集計してからメインクエリで JOIN する」というアプローチです。

WITH child_a_totals AS (
  SELECT parent_id, SUM(value) AS total
  FROM child_a
  GROUP BY parent_id
),
child_b_counts AS (
  SELECT parent_id, COUNT(*) AS item_count
  FROM child_b
  GROUP BY parent_id
)
SELECT p.id, a.total, b.item_count
FROM parents p
LEFT JOIN child_a_totals a ON p.id = a.parent_id
LEFT JOIN child_b_counts b ON p.id = b.parent_id;
Fan-outの恐怖: もし user_id=1 が注文を3回、レビューを2回書いた場合、単純JOINすると 3 × 2 = 6行 のデータが生成され、注文合計金額が2倍に水増しされてしまいます。
問題

users テーブルの各ユーザーについて、「注文の合計金額」と「レビューの件数」を一覧表示してください。

  1. CTE user_orders: orders を集計し、ユーザーごとの合計金額(total_amount)を算出。
  2. CTE user_reviews: reviews を集計し、ユーザーごとのレビュー件数(review_count)を算出。
  3. メインクエリ: users を軸に、上記の2つのCTEを LEFT JOIN し、注文やレビューがない場合は COALESCE を使って 0 を表示してください。
使用テーブル
▸ users
user_idname
1田中
2佐藤
▸ orders
order_iduser_idamount
115000
213000
3210000
▸ reviews
iduser_idrating
115
214
期待出力
nametotal_amountreview_count
田中80002
佐藤100000
模範解答コード
-- ① 1つ目のCTE: orders をユーザー単位に事前集計(1対1にする)
WITH user_orders AS (
  SELECT user_id, SUM(amount) AS total_amount
  FROM orders
  GROUP BY user_id
),
user_reviews AS (                              -- ② 2つ目のCTE: reviews もユーザー単位に事前集計(1対1にする)
  SELECT user_id, COUNT(*) AS review_count
  FROM reviews
  GROUP BY user_id
)

-- ③ メインクエリ: users を主軸に、集計済みのCTEを安全に LEFT JOIN する
SELECT
  u.name,
  COALESCE(o.total_amount, 0) AS total_amount, -- NULL なら 0 に変換
  COALESCE(r.review_count, 0) AS review_count  -- NULL なら 0 に変換
FROM users u
LEFT JOIN user_orders o ON u.user_id = o.user_id
LEFT JOIN user_reviews r ON u.user_id = r.user_id
ORDER BY u.user_id;

/*
  実行順序:
  1. user_orders (CTE)   → 注文を集計
  2. user_reviews (CTE)  → レビューを集計
  3. users に LEFT JOIN   → 両CTEを結合(未一致はNULL)
  4. COALESCE            → review_count の NULL を 0 に変換
  */
解説(テーブル変化・ポイント)
WITH user_orders AS ( SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id ), user_reviews AS ( SELECT user_id, COUNT(*) AS review_count FROM reviews GROUP BY user_id ) SELECT u.name, COALESCE(o.total_amount, 0) AS total_amount, COALESCE(r.review_count, 0) AS review_count FROM users u LEFT JOIN user_orders o ON u.user_id = o.user_id LEFT JOIN user_reviews r ON u.user_id = r.user_id ORDER BY u.user_id;
LEGEND
グループ化キー・集計対象
グループ分類
① 複数のCTEで事前集計
CTE: user_orders / user_reviewsorders と reviews の両方を事前にユーザー単位でグループ化し、「1ユーザーにつき1行」の状態にしておきます。これが Fan-out(行増殖)を防ぐ最大のポイントです。
1 / 3
▸ user_orders (CTE)
user_idtotal_amount
18000
210000
▸ user_reviews (CTE)
user_idreview_count
12
orders 2行 / reviews 1行 のCTEが完成
学習ポイント
Fan-out(行増殖)の回避とCTE:1対多のテーブルを複数同時にJOINすると、データが掛け算で増殖します。これを防ぐための「JOINする前に各子テーブルを1行(1対1)に集約する(事前集計)」という処理単位を明示的に作る上で、CTEは最も適した構文です。
COALESCE(コアレス)関数の必須性:LEFT JOIN を使うと、子テーブルにデータが存在しない場合に NULL が返ります。APIレスポンスで total_amount: null が返るとフロントエンドで計算エラー(NaN)を起こすため、COALESCE(値, 0) で確実に数値の 0 に変換して返すのがAPI開発の鉄則です。
QUESTION 7

CTE × コホート分析 — 条件で分けたグループ同士を比較する

条件比較INNER JOIN差分・共通項コホート分析
前提知識

1つのテーブル内で「Aの条件を満たす」かつ「Bの条件を満たす」という、異なる時系列や状態の比較を行うのは簡単ではありません。

このようなケースでは、「Aの条件を満たすリストをCTEで作り、Bの条件を満たすリストもCTEで作り、両者を JOIN して共通項を探す」というアプローチが極めて直感的で効果的です。

WITH set_a AS (
  SELECT DISTINCT id FROM events WHERE condition_a
),
set_b AS (
  SELECT DISTINCT id FROM events WHERE condition_b
)
SELECT a.id
FROM set_a a
JOIN set_b b ON a.id = b.id;
問題

orders テーブルから、「2024年1月に購入し、かつ、2024年2月にも購入したリピーター」の user_id を抽出してください。

  1. CTE jan_users: 1月に注文した user_id を抽出(重複排除)。
  2. CTE feb_users: 2月に注文した user_id を抽出(重複排除)。
  3. メインクエリ: 両方のCTEを INNER JOIN し、両月に存在する user_id を出力してください。
使用テーブル
▸ orders
order_iduser_idordered_at
112024-01-15
222024-01-20
322024-02-10
432024-02-15
512024-03-05
期待出力
user_id
2
模範解答コード
-- ① 1月の購入者リストを作成
WITH jan_users AS (
  SELECT DISTINCT user_id           -- 1月に複数回買った場合も1行にする
  FROM orders
  WHERE ordered_at >= '2024-01-01'
    AND ordered_at <  '2024-02-01'
),
feb_users AS (                      -- ② 2月の購入者リストを作成
  SELECT DISTINCT user_id
  FROM orders
  WHERE ordered_at >= '2024-02-01'
    AND ordered_at <  '2024-03-01'
)

-- ③ メインクエリで両者を結合し、両月に存在するユーザーを特定
SELECT
  j.user_id
FROM jan_users j
JOIN feb_users f            -- INNER JOIN: 両方に存在するIDのみが残る
  ON j.user_id = f.user_id;

/*
  実行順序:
  1. jan_users   → 1月の注文から user_id を抽出
  2. feb_users   → 2月の注文から user_id を抽出
  3. INNER JOIN  → 両方に存在する user_id だけ残す
  */
解説(テーブル変化・ポイント)
WITH jan_users AS ( SELECT DISTINCT user_id FROM orders WHERE ordered_at >= '2024-01-01' AND ordered_at < '2024-02-01' ), feb_users AS ( SELECT DISTINCT user_id FROM orders WHERE ordered_at >= '2024-02-01' AND ordered_at < '2024-03-01' ) SELECT j.user_id FROM jan_users j JOIN feb_users f ON j.user_id = f.user_id;
LEGEND
評価対象の列・キー
① 各条件でのリスト作成
CTE: jan_users / feb_users「1月に購入したユーザー」と「2月に購入したユーザー」のリストを、それぞれ独立したCTEとして作成します。複雑な条件も分割すればシンプルになります。
1 / 3
▸ jan_users (CTE)
user_id
1
2
▸ feb_users (CTE)
user_id
2
3
jan: 2名 / feb: 2名
学習ポイント
差分・共通項の抽出(集合演算の代替):今回 INNER JOIN で共通項を抽出しましたが、「1月には買ったが、2月には買わなかった離脱ユーザー」を探す場合は、LEFT JOIN feb_users f を行い、WHERE f.user_id IS NULL とすることで簡単に差分抽出が可能です(復習)。
日付の範囲指定:BETWEEN '2024-01-01' AND '2024-01-31' は、時刻データが含まれている場合 2024-01-31 15:00:00 が範囲外になってしまうバグを生みやすいです。必ず >= 月初 かつ < 翌月初 の形(ハーフオープン区間)で書くのがプロの作法です。
QUESTION 8

CTE × ウィンドウ関数 — モダンな最新行の取得

ROW_NUMBERウィンドウ関数最新行取得モダンSQL
前提知識

「MAX日付を集計して元テーブルとJOINする手法」は確実ですが、同じ日付の注文が複数あると行が増殖する弱点があります。

現代のSQL(PostgreSQL 8.4以降, MySQL 8.0以降等)では、ウィンドウ関数 ROW_NUMBER() と CTE を組み合わせる方法が、最新行取得のベストプラクティス(デファクトスタンダード)となっています。

-- ROW_NUMBER() OVER (PARTITION BY グループ化キー ORDER BY 並び順)
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ordered_at DESC)
-- → user_id ごとに、ordered_at の新しい順で 1, 2, 3... と連番を振る
なぜCTEが必要か?: SQLの仕様上、WHERE 句の中で ROW_NUMBER() = 1 と直接書くことはできません。そのため、CTEで連番を付与してから、外側のメインクエリで WHERE rn = 1 として絞り込む必要があります。
問題

orders テーブルから、ROW_NUMBER() と CTE を使って各ユーザーの最新の注文データを取得してください。

  1. CTE ranked_orders: ROW_NUMBER() を使い、user_id ごとに ordered_at の降順(新しい順)で連番(rn)を振る。
  2. メインクエリ: CTE から rn = 1(最新の行)だけを抽出し、user_id, order_id, amount, ordered_at を出力する。
使用テーブル
▸ orders
order_iduser_idamountordered_at
1130002024-01-10
2150002024-02-15
32100002024-01-20
4280002024-03-01
期待出力
user_idorder_idamountordered_at
1250002024-02-15
2480002024-03-01
模範解答コード
-- ① CTE: ウィンドウ関数を使って、グループごとの連番を付与した一時テーブルを作る
WITH ranked_orders AS (
  SELECT
    user_id,
    order_id,
    amount,
    ordered_at,
    ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ordered_at DESC) AS rn  -- ユーザーごとに新しい順で連番
  FROM orders
)

-- ② メインクエリ: 連番(rn)が 1 の行 = 各ユーザーの最新行 だけを抽出
SELECT
  user_id,
  order_id,
  amount,
  ordered_at
FROM ranked_orders
WHERE rn = 1        -- ここで最新行に絞り込む(ウィンドウ関数はWHEREに直接書けないためCTEが必須)
ORDER BY user_id;

/*
  実行順序:
  1. ranked_orders (CTE)  → user_id ごとに順位(rn)を付与
  2. メインクエリ WHERE rn=1    → 各ユーザーの最新行だけ残す
  */
解説(テーブル変化・ポイント)
WITH ranked_orders AS ( SELECT user_id, order_id, amount, ordered_at, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ordered_at DESC) AS rn FROM orders ) SELECT user_id, order_id, amount, ordered_at FROM ranked_orders WHERE rn = 1 ORDER BY user_id;
LEGEND
評価対象の列・キー
① ウィンドウ関数で連番付与
PARTITION BY user_id ORDER BY ordered_at DESCPARTITION BY でユーザーごとに区切り、ORDER BY で日付の新しい順に並べ、ROW_NUMBER() で 1, 2, 3... と連番(順位)を付与した一時テーブルを作成します。
1 / 2
order_iduser_idordered_atrn (連番)
212024-02-15 (最新)1
112024-01-10 (古い)2
422024-03-01 (最新)1
322024-01-20 (古い)2
全4行に連番が付与される
学習ポイント
MAX + JOIN と ROW_NUMBER() の決定的な違い:MAX + JOIN 方式は、たまたま同じ秒数に2件の注文があった場合、JOINで両方が引っかかり行が2倍に増殖するバグの危険があります。一方 ROW_NUMBER() は同着であっても必ず「1, 2」と一意な連番を強制的に振るため、rn = 1 で抽出する限り行の増殖は絶対に起こりません。これが実務で ROW_NUMBER() が好まれる最大の理由です。
TOP N クエリへの応用:WHERE rn = 1WHERE rn <= 3 に変えるだけで、「各ユーザーの最新の注文トップ3」を簡単に取得できます。これもウィンドウ関数の強みです。
QUESTION 9

CTE × グループ平均 — 相関サブクエリのモダンな代替

グループ平均分析基盤サブクエリ代替比較分析
前提知識

「各カテゴリの中で、そのカテゴリの平均価格よりも高い商品を抽出する」というような、個別の行とその行が属するグループの集計値(平均など)を比較する処理は、分析基盤で頻出します。

昔は相関サブクエリ(SELECT句やWHERE句の中で毎回SELECTを発行する書き方)が使われていましたが、読みづらくパフォーマンスも悪化しがちでした。「CTEでカテゴリごとの平均を計算し、メインクエリでJOINして比較する」アプローチが現在では主流です。

WITH group_stats AS (
  SELECT group_id, AVG(value) AS avg_value
  FROM items
  GROUP BY group_id
)
SELECT i.id, i.value, g.avg_value
FROM items i
JOIN group_stats g ON i.group_id = g.group_id
WHERE i.value >= g.avg_value;
問題

products テーブルから、「自分が属するカテゴリの平均価格以上の価格を持つ商品」を抽出してください。

  1. CTE category_avgs: category_id ごとに平均価格(avg_price)を集計する。
  2. メインクエリ: productscategory_avgs を結合し、price >= avg_price の条件で絞り込む。
  3. 商品の id, name, price, avg_price を出力する。
使用テーブル
▸ products
idnamecategory_idprice
1ノートPC1120000
2マウス15000
3モニター125000
4小説A21000
5専門書B25000
期待出力
idnamepriceavg_price
1ノートPC12000050000
5専門書B50003000
模範解答コード
-- ① CTE: カテゴリごとの平均価格を計算
WITH category_avgs AS (
  SELECT
    category_id,
    AVG(price) AS avg_price
  FROM products
  GROUP BY category_id
)

-- ② メインクエリ: 商品データと、CTEで計算したカテゴリ平均を結合し比較
SELECT
  p.id,
  p.name,
  p.price,
  c.avg_price
FROM products p
JOIN category_avgs c
  ON p.category_id = c.category_id
WHERE p.price >= c.avg_price      -- 商品価格がカテゴリ平均以上かどうか判定
ORDER BY p.id;

/*
  実行順序:
  1. category_avgs  → カテゴリごとの平均価格を集計
  2. JOIN           → 各商品に自カテゴリの平均を結合
  3. WHERE          → 平均以上の商品だけ残す
  */
解説(テーブル変化・ポイント)
WITH category_avgs AS ( SELECT category_id, AVG(price) AS avg_price FROM products GROUP BY category_id ) SELECT p.id, p.name, p.price, c.avg_price FROM products p JOIN category_avgs c ON p.category_id = c.category_id WHERE p.price >= c.avg_price ORDER BY p.id;
LEGEND
グループ化キー・集計対象
グループ分類
① カテゴリごとの平均値算出
GROUP BY category_id → AVG(price)まず、カテゴリごとの平均価格を計算し、category_avgs という一時テーブルを作ります。後で個別の商品と比較するための「基準値」の準備です。
1 / 3
category_idavg_price
150000
23000
5行 → 2グループ (CTE完成)
学習ポイント
相関サブクエリからの脱却:この処理は WHERE price >= (SELECT AVG(price) FROM products p2 WHERE p.category_id = p2.category_id) と書くこともできます(相関サブクエリ)。しかし、これだとデータ1行ごとにサブクエリが実行されるためパフォーマンスが劣化しやすいです。CTEを使って「先に一括で集計してからJOIN」する方が、高速かつモダンな書き方です。(※Window関数の AVG() OVER(PARTITION BY ...) を使っても同様にスッキリ書けます)
QUESTION 10

再帰CTE (WITH RECURSIVE) — 階層構造(ツリー)を辿る

WITH RECURSIVE再帰ツリー構造階層データ
前提知識

CTEの究極の機能が、WITH RECURSIVE(再帰CTE)です。これは「ある結果を使って、さらに次の検索を行う」という処理を、データがなくなるまで繰り返す機能です。

ECサイトのカテゴリツリー(親カテゴリ → 子カテゴリ → 孫カテゴリ...)や、掲示板のコメントへの返信ツリー、組織図など、階層の深さが無限に続く可能性があるデータを1回のSQLで一括取得するために使用されます。

WITH RECURSIVE tree AS (
  SELECT id, parent_id              -- アンカー部(開始点)
  FROM nodes
  WHERE id = 1

  UNION ALL

  SELECT n.id, n.parent_id         -- 再帰部(次の階層)
  FROM nodes n
  JOIN tree t ON n.parent_id = t.id
)
SELECT * FROM tree;
再帰CTEの構文ルール:
1. 非再帰項(初期ステップ): 最初の出発点となるデータを SELECT する。
2. UNION ALL: 上下を繋ぐ必須のキーワード。
3. 再帰項(繰り返しステップ): 元テーブルと「自分自身のCTE」を JOIN し、次の階層を探す。
問題

categories テーブルは、parent_id によって親子関係を持っています(parent_idNULL なら最上位の親)。

再帰CTEを使用して、id = 1(家電)を起点とし、その下層に属するすべての子孫カテゴリ(子・孫など)を取得してください。

使用テーブル
▸ categories
idnameparent_id
1家電NULL
2PC1
3ノートPC2
4デスクトップ2
5書籍NULL
期待出力
idnameparent_id
1家電NULL
2PC1
3ノートPC2
4デスクトップ2
模範解答コード
-- RECURSIVE をつけることで、CTEの中で自分自身(category_tree)を呼び出せるようになる
WITH RECURSIVE category_tree AS (

  -- 【ステップ1:非再帰項(出発点)】
  -- まず、起点となる id = 1(家電)の行だけを取得する
  SELECT id, name, parent_id
  FROM categories
  WHERE id = 1

  UNION ALL  -- 上の結果と、下の繰り返しの結果を合体させる

  -- 【ステップ2:再帰項(繰り返し処理)】
  -- 直前に見つかった階層(ct) を親(parent_id)として持つ 子(c) を探し出す
  SELECT
    c.id, c.name, c.parent_id
  FROM categories c
  JOIN category_tree ct        -- 自分自身のCTEを JOIN する!
    ON c.parent_id = ct.id     -- 子の parent_id が、親の id と一致するものを探す
)

-- 【ステップ3:最終出力】
SELECT * FROM category_tree
ORDER BY id;

/*
  実行のシミュレーション:
  1. 初期: WHERE id = 1 により「家電(id=1)」が抽出される。
  2. 1周目: 親(ct)が「家電(id=1)」のもの。→ parent_id=1 である「PC(id=2)」が抽出される。
  3. 2周目: 親(ct)が「PC(id=2)」のもの。→ parent_id=2 である「ノートPC(id=3), デスクトップ(id=4)」が抽出。
  4. 3周目: 親(ct)が「ノートPC, デスク」のもの。→ 見つからないため、ここで再帰ループが終了。
  5. 最終的に抽出された 1, 2, 3, 4 が合体して返される。(書籍=5 はツリーが違うため除外)
*/
解説(テーブル変化・ポイント)
WITH RECURSIVE category_tree AS ( SELECT id, name, parent_id FROM categories WHERE id = 1 UNION ALL SELECT c.id, c.name, c.parent_id FROM categories c JOIN category_tree ct ON c.parent_id = ct.id ) SELECT * FROM category_tree;
LEGEND
評価対象の列・キー
除外・非表示データ
① 非再帰項 (出発点の取得)
WHERE id = 1WITH RECURSIVE の最初のステップです。起点となる id=1(家電)を抽出し、これを階層ツリーの「第1階層」として保持します。
1 / 4
idnameparent_id
1家電NULL
2PC1
3ノートPC2
4デスクトップ2
5書籍NULL
取得完了: 第1階層 (家電)
学習ポイント
N+1問題の完全排除:プログラム側で「親カテゴリを取得 → そのidを使って子カテゴリを取得 → そのidを使って…」とループ処理を回すと、階層が深いほど膨大なDBアクセスが発生します。WITH RECURSIVE を使えば、DB側でツリーを辿りきってから1回のレスポンスで返してくれるため、圧倒的なパフォーマンス改善になります。
無限ループに注意:もしデータに誤りがあり、Aの親がB、Bの親がAという「循環参照」になっていた場合、再帰が終わらず無限ループになります(DBに多大な負荷がかかります)。実務ではデータ整合性に注意するか、再帰回数を制限する安全策を組み込むこともあります。