事前集計でFan-out防止 — 結合前に集計して行増殖を防ぐ
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;
users テーブルの各ユーザーについて、「注文の合計金額」と「レビューの件数」を一覧表示してください。
- CTE
user_orders:ordersを集計し、ユーザーごとの合計金額(total_amount)を算出。 - CTE
user_reviews:reviewsを集計し、ユーザーごとのレビュー件数(review_count)を算出。 - メインクエリ:
usersを軸に、上記の2つのCTEをLEFT JOINし、注文やレビューがない場合はCOALESCEを使って0を表示してください。
| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| order_id | user_id | amount |
|---|---|---|
| 1 | 1 | 5000 |
| 2 | 1 | 3000 |
| 3 | 2 | 10000 |
| id | user_id | rating |
|---|---|---|
| 1 | 1 | 5 |
| 2 | 1 | 4 |
| name | total_amount | review_count |
|---|---|---|
| 田中 | 8000 | 2 |
| 佐藤 | 10000 | 0 |
-- ① 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 に変換 */
LEGEND
① 複数のCTEで事前集計
CTE: user_orders / user_reviewsorders と reviews の両方を事前にユーザー単位でグループ化し、「1ユーザーにつき1行」の状態にしておきます。これが Fan-out(行増殖)を防ぐ最大のポイントです。| user_id | total_amount |
|---|---|
| 1 | 8000 |
| 2 | 10000 |
| user_id | review_count |
|---|---|
| 1 | 2 |
total_amount: null が返るとフロントエンドで計算エラー(NaN)を起こすため、COALESCE(値, 0) で確実に数値の 0 に変換して返すのがAPI開発の鉄則です。CTE × コホート分析 — 条件で分けたグループ同士を比較する
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 を抽出してください。
- CTE
jan_users: 1月に注文したuser_idを抽出(重複排除)。 - CTE
feb_users: 2月に注文したuser_idを抽出(重複排除)。 - メインクエリ: 両方のCTEを INNER JOIN し、両月に存在する
user_idを出力してください。
| order_id | user_id | ordered_at |
|---|---|---|
| 1 | 1 | 2024-01-15 |
| 2 | 2 | 2024-01-20 |
| 3 | 2 | 2024-02-10 |
| 4 | 3 | 2024-02-15 |
| 5 | 1 | 2024-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 だけ残す */
LEGEND
① 各条件でのリスト作成
CTE: jan_users / feb_users「1月に購入したユーザー」と「2月に購入したユーザー」のリストを、それぞれ独立したCTEとして作成します。複雑な条件も分割すればシンプルになります。| user_id |
|---|
| 1 |
| 2 |
| user_id |
|---|
| 2 |
| 3 |
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 が範囲外になってしまうバグを生みやすいです。必ず >= 月初 かつ < 翌月初 の形(ハーフオープン区間)で書くのがプロの作法です。CTE × ウィンドウ関数 — モダンな最新行の取得
「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... と連番を振る
WHERE 句の中で ROW_NUMBER() = 1 と直接書くことはできません。そのため、CTEで連番を付与してから、外側のメインクエリで WHERE rn = 1 として絞り込む必要があります。orders テーブルから、ROW_NUMBER() と CTE を使って各ユーザーの最新の注文データを取得してください。
- CTE
ranked_orders:ROW_NUMBER()を使い、user_idごとにordered_atの降順(新しい順)で連番(rn)を振る。 - メインクエリ: CTE から
rn = 1(最新の行)だけを抽出し、user_id,order_id,amount,ordered_atを出力する。
| order_id | user_id | amount | ordered_at |
|---|---|---|---|
| 1 | 1 | 3000 | 2024-01-10 |
| 2 | 1 | 5000 | 2024-02-15 |
| 3 | 2 | 10000 | 2024-01-20 |
| 4 | 2 | 8000 | 2024-03-01 |
| user_id | order_id | amount | ordered_at |
|---|---|---|---|
| 1 | 2 | 5000 | 2024-02-15 |
| 2 | 4 | 8000 | 2024-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 → 各ユーザーの最新行だけ残す */
LEGEND
① ウィンドウ関数で連番付与
PARTITION BY user_id ORDER BY ordered_at DESCPARTITION BY でユーザーごとに区切り、ORDER BY で日付の新しい順に並べ、ROW_NUMBER() で 1, 2, 3... と連番(順位)を付与した一時テーブルを作成します。| order_id | user_id | ordered_at | rn (連番) |
|---|---|---|---|
| 2 | 1 | 2024-02-15 (最新) | 1 |
| 1 | 1 | 2024-01-10 (古い) | 2 |
| 4 | 2 | 2024-03-01 (最新) | 1 |
| 3 | 2 | 2024-01-20 (古い) | 2 |
ROW_NUMBER() は同着であっても必ず「1, 2」と一意な連番を強制的に振るため、rn = 1 で抽出する限り行の増殖は絶対に起こりません。これが実務で ROW_NUMBER() が好まれる最大の理由です。WHERE rn = 1 を WHERE rn <= 3 に変えるだけで、「各ユーザーの最新の注文トップ3」を簡単に取得できます。これもウィンドウ関数の強みです。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 テーブルから、「自分が属するカテゴリの平均価格以上の価格を持つ商品」を抽出してください。
- CTE
category_avgs:category_idごとに平均価格(avg_price)を集計する。 - メインクエリ:
productsとcategory_avgsを結合し、price >= avg_priceの条件で絞り込む。 - 商品の
id,name,price,avg_priceを出力する。
| id | name | category_id | price |
|---|---|---|---|
| 1 | ノートPC | 1 | 120000 |
| 2 | マウス | 1 | 5000 |
| 3 | モニター | 1 | 25000 |
| 4 | 小説A | 2 | 1000 |
| 5 | 専門書B | 2 | 5000 |
| id | name | price | avg_price |
|---|---|---|---|
| 1 | ノートPC | 120000 | 50000 |
| 5 | 専門書B | 5000 | 3000 |
-- ① 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 → 平均以上の商品だけ残す */
LEGEND
① カテゴリごとの平均値算出
GROUP BY category_id → AVG(price)まず、カテゴリごとの平均価格を計算し、category_avgs という一時テーブルを作ります。後で個別の商品と比較するための「基準値」の準備です。| category_id | avg_price |
|---|---|
| 1 | 50000 |
| 2 | 3000 |
WHERE price >= (SELECT AVG(price) FROM products p2 WHERE p.category_id = p2.category_id) と書くこともできます(相関サブクエリ)。しかし、これだとデータ1行ごとにサブクエリが実行されるためパフォーマンスが劣化しやすいです。CTEを使って「先に一括で集計してからJOIN」する方が、高速かつモダンな書き方です。(※Window関数の AVG() OVER(PARTITION BY ...) を使っても同様にスッキリ書けます)再帰CTE (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;
1. 非再帰項(初期ステップ): 最初の出発点となるデータを SELECT する。
2. UNION ALL: 上下を繋ぐ必須のキーワード。
3. 再帰項(繰り返しステップ): 元テーブルと「自分自身のCTE」を JOIN し、次の階層を探す。
categories テーブルは、parent_id によって親子関係を持っています(parent_id が NULL なら最上位の親)。
再帰CTEを使用して、id = 1(家電)を起点とし、その下層に属するすべての子孫カテゴリ(子・孫など)を取得してください。
| id | name | parent_id |
|---|---|---|
| 1 | 家電 | NULL |
| 2 | PC | 1 |
| 3 | ノートPC | 2 |
| 4 | デスクトップ | 2 |
| 5 | 書籍 | NULL |
| id | name | parent_id |
|---|---|---|
| 1 | 家電 | NULL |
| 2 | PC | 1 |
| 3 | ノートPC | 2 |
| 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 はツリーが違うため除外) */
LEGEND
① 非再帰項 (出発点の取得)
WHERE id = 1WITH RECURSIVE の最初のステップです。起点となる id=1(家電)を抽出し、これを階層ツリーの「第1階層」として保持します。| id | name | parent_id |
|---|---|---|
| 1 | 家電 | NULL |
| 2 | PC | 1 |
| 3 | ノートPC | 2 |
| 4 | デスクトップ | 2 |
| 5 | 書籍 | NULL |
WITH RECURSIVE を使えば、DB側でツリーを辿りきってから1回のレスポンスで返してくれるため、圧倒的なパフォーマンス改善になります。