SQL CTE・WITH句 — 複数CTE・再帰CTE・クエリ分割の応用

応用CTE複数CTE・再帰CTEAPI実務PostgreSQL/BigQuery対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

基本CTE(WITH句) — サブクエリをCTEに置き換えて可読性を上げる

WITHCTEGROUP BYAPIサマリ
前提知識

CTE(Common Table Expression)は WITH 名前 AS (...) の形式で定義する「一時的な名前付きクエリ」です。サブクエリをネストする代わりにCTEに切り出すことで、SQLが上から下へ読めるようになります。

WITH cte_name AS (
  -- ここに SELECT 文を書く(一時テーブルのイメージ)
  SELECT col1, SUM(col2) AS total
  FROM   some_table
  GROUP BY col1
)
-- CTE定義の後に本体のSELECTを書く
SELECT *
FROM   cte_name
WHERE  total > 10000;

CTEは SELECT / INSERT / UPDATE / DELETE すべてで使えます。本クイズではSELECTのみ扱います。

なぜCTEを使うか:サブクエリは外側のSELECTと入れ子になるため、深くなると読みづらい。CTEは「先にこの計算を定義してから使う」という宣言的な書き方ができ、コードレビューや保守が格段に楽になります。APIバックエンドで複雑なSQLを扱う際に特に有効です。
問題

以下の orders テーブルから、月ごとの売上合計・注文件数・平均注文額を集計するAPIを想定し、CTEを使ってクエリを作成してください。

さらに、平均注文額が 40,000円超の月のみ を最終結果として返してください。

使用テーブル
▸ orders
order_idorder_monthamount
12024-0130000
22024-0145000
32024-0120000
42024-0255000
52024-0260000
62024-0380000
72024-0315000
82024-0325000
期待出力
order_monthtotal_salesorder_countavg_order
2024-02115000257500
模範解答コード
-- CTEを定義する: WITH句を使うことで、サブクエリを一時テーブルのように扱える
WITH monthly_summary AS (

  -- ① orders テーブルを月ごとに集計した「中間テーブル」を作る
  SELECT                           -- SELECT: 取得する列を指定
    order_month,                   -- 月ごとに集計するキー列
    SUM(amount)   AS total_sales,  -- SUM: 月の売上合計を計算。ASで別名をつける
    COUNT(*)      AS order_count,  -- COUNT(*): 全行数 = 注文件数を計算
    AVG(amount)   AS avg_order     -- AVG: 月の平均注文額を計算
  FROM     orders                  -- FROM: データを取得する元テーブルを指定
  GROUP BY order_month             -- GROUP BY: 同じ月の行をまとめて集計

)

-- ② CTE の結果を「テーブルのように」FROM に指定して使う
SELECT                              -- SELECT: 最終的に出力する列を指定
  order_month,
  total_sales,
  order_count,
  ROUND(avg_order, 0) AS avg_order  -- ROUND(値, 桁数): 小数を四捨五入 (0なら整数に)
FROM   monthly_summary              -- FROM: 上で定義したCTEを指定
WHERE  avg_order > 40000            -- WHERE: 条件に合う行のみに絞り込む(40000より大きい)
ORDER BY order_month;               -- ORDER BY: 月の昇順(小さい順)で並び替え

/*
  実行順序:
  1. CTE内 FROM orders            → テーブル全件読み込み
  2. CTE内 GROUP BY order_month   → 月ごとにグループ化・SUM/COUNT/AVG集計
  3. 外側 FROM monthly_summary     → CTE結果を仮想テーブルとして参照
  4. 外側 WHERE avg_order > 40000  → フィルタ適用
  5. 外側 ORDER BY order_month     → 昇順並び替え
  */
解説(テーブル変化・ポイント)
WITH monthly_summary AS ( SELECT order_month, SUM(amount) AS total_sales, COUNT(*) AS order_count, AVG(amount) AS avg_order FROM orders GROUP BY order_month ) SELECT order_month, total_sales, order_count, ROUND(avg_order, 0) AS avg_order FROM monthly_summary WHERE avg_order > 40000 ORDER BY order_month;
LEGEND
データ取得・読込対象
① FROM
FROM ordersorders テーブル全 8行を読み込みます。これが CTE monthly_summary の入力データです。
1 / 4
order_idorder_monthamount
12024-0130,000
22024-0145,000
32024-0120,000
42024-0255,000
52024-0260,000
62024-0380,000
72024-0315,000
82024-0325,000
全 8行 読込
学習ポイント
CTEは「使い捨ての名前付きクエリ」:定義したクエリの中でのみ有効で、クエリが終わると消えます。永続化するにはビュー(VIEW)かテーブル(CREATE TABLE AS)を使います。
WHERE vs HAVING:通常 HAVING avg(amount) > 40000 でも同じ結果ですが、CTEにすると「集計ロジック」と「フィルタロジック」が分離でき、条件変更が容易です。
アンチパターン
CTEの途中にセミコロンを置く:WITH cte AS (...); SELECT ... は文法エラー。WITHからSELECTの末尾まで1つのSQL文です。
実務コラム
ダッシュボードでの集計処理:月次売上サマリAPIは、管理画面で最も頻繁に叩かれるエンドポイントの1つです。WITH句での集計対象(今回はorders全件)が数千万件など膨大になる場合、毎回リアルタイムで計算するとAPIが重くなります。実務では、このCTEにあたる集計処理を夜間バッチ等で「サマリーテーブル(データマート)」として事前に作成しておくアーキテクチャがよく採用されます。
QUESTION 2

複数CTE連結 — ステップを分けてユーザー購買ランクを計算する

WITH複数CTEJOINランク付けAPINTILE
前提知識

CTEは WITH の後に カンマ区切りで複数定義でき、後に定義したCTEは前のCTEを参照できます。「ステップ①の結果をステップ②で使う」というパイプライン処理をSQLで表現できます。

WITH
step1 AS (
  SELECT user_id, SUM(amount) AS total
  FROM   orders
  GROUP BY user_id
),
-- step2 は step1 を参照できる(カンマで区切って連続定義)
step2 AS (
  SELECT *,
         NTILE(4) OVER (ORDER BY total DESC) AS quartile
  FROM   step1   -- 前のCTEをテーブルのように参照
)
SELECT * FROM step2;
NTILE(n):行を n 等分してバケット番号(1〜n)を振るウィンドウ関数。NTILE(4) は上位25%が1、次の25%が2…と四分位に分類します。顧客セグメント分析で頻出です。
問題

以下の usersorders テーブルを使って、ユーザーごとの合計購入額を集計し、上位から4分位(NTILE)でランクを付けるAPIを作成してください。

ランクは quartile=1 が最上位。最終出力には users テーブルから user_name も結合してください。

使用テーブル
▸ users
user_iduser_name
U01Alice
U02Bob
U03Carol
U04Dave
U05Eve
U06Frank
U07Grace
U08Hank
▸ orders
order_iduser_idamount
1U01120000
2U0150000
3U0230000
4U03200000
5U0475000
6U0510000
7U0695000
8U0740000
9U08160000
10U0620000
期待出力
user_iduser_nametotal_amountquartile
U03Carol2000001
U01Alice1700001
U08Hank1600002
U06Frank1150002
U04Dave750003
U07Grace400003
U02Bob300004
U05Eve100004
模範解答コード
WITH
user_totals AS (                  -- ① ユーザーごとに注文を合計する(中間集計)
  SELECT                          -- SELECT: 取得する列を指定
    user_id,
    SUM(amount) AS total_amount   -- SUM: 複数注文を合算。ASで別名をつける
  FROM     orders                 -- FROM: 取得元テーブル
  GROUP BY user_id                -- GROUP BY: ユーザー単位でグループ化
),
user_ranked AS (                  -- ② user_totals を使って NTILE で4分位を付ける(前のCTEを参照)
  SELECT
    user_id,
    total_amount,
    NTILE(4) OVER (               -- NTILE(4): 全行を4等分してバケット番号を付与
      ORDER BY total_amount DESC  -- ORDER BY ... DESC: 購入額の降順(大きい順)で並べる
    ) AS quartile
  FROM   user_totals              -- FROM: 前のCTE(user_totals)をテーブルとして参照
)

-- ③ 本体: user_ranked に users テーブルを JOIN して名前を付加
SELECT
  r.user_id,
  u.user_name,                           -- users テーブルから名前を取得(結合して追加)
  r.total_amount,
  r.quartile
FROM       user_ranked r                 -- FROM: r は user_ranked のエイリアス(短縮名)
INNER JOIN users       u                 -- INNER JOIN: 両方のテーブルに存在する行のみ結合
        ON r.user_id = u.user_id         -- ON: 結合条件(user_idが一致する行を繋げる)
ORDER BY  r.quartile, r.total_amount DESC; -- ORDER BY: 四分位の昇順 → 購入額降順の順で並べる

/*
  実行順序:
  1. CTE user_totals: FROM orders                      → GROUP BY user_id
  2. CTE user_ranked: FROM user_totals
  3. 本体: FROM user_ranked INNER JOIN users ON user_id  → 名前付加
  4. 本体: ORDER BY quartile, total_amount DESC
  */
解説(テーブル変化・ポイント)
WITH user_totals AS ( SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id ), user_ranked AS ( SELECT user_id, total_amount, NTILE(4) OVER ( ORDER BY total_amount DESC ) AS quartile FROM user_totals ) SELECT r.user_id, u.user_name, r.total_amount, r.quartile FROM user_ranked r INNER JOIN users u ON r.user_id = u.user_id ORDER BY r.quartile, r.total_amount DESC;
LEGEND
データ取得・読込対象
① FROM
FROM ordersorders テーブル全 10行を読み込みます。これが最初の CTE user_totals の入力データになります。
1 / 5
order_iduser_idamount
1U01120,000
2U0150,000
3U0230,000
4U03200,000
5U0475,000
6U0510,000
7U0695,000
8U0740,000
9U08160,000
10U0620,000
全 10行 読込
学習ポイント
CTEパイプラインの実務価値:「集計→ランク付け→結合」の3ステップを宣言的に分離できます。NTILEを5分位に変えたいなら user_ranked のCTEだけ修正すればOKです。
NTILE vs RANK:RANK() は値の大小で順位を付けますが、NTILE(n) は行数をn等分してバケット番号を付けます。同率があっても強制的に振り分けられる点が異なります。
アンチパターン
後のCTEが前のCTEを逆方向に参照:CTEは定義順にしか参照できません。step2 の中で step3 を使うことはできません(再帰CTEを除く)。
実務コラム
CRMのセグメント配信:NTILEを使ったランク付けは、メルマガやPush通知のセグメント分け(上位25%には限定VIPオファー、下位25%には再訪を促すクーポンなど)でそのまま活用できます。実務では、計算リソースを節約するため、全期間の購入ではなく「直近1年間」のようにWHERE句で事前にデータを絞ってからNTILEにかけるのが一般的です。
QUESTION 3

CTE + ROW_NUMBER — カテゴリ別トップN商品を抽出する

CTEROW_NUMBERPARTITION BYトップN抽出ランキングAPI
前提知識

「カテゴリ別の上位N件だけ取得する」というAPIは非常に頻出です。ROW_NUMBER() + CTE の組み合わせが最もシンプルで汎用性が高いパターンです。

WITH ranked AS (
  SELECT *,
    ROW_NUMBER() OVER (
      PARTITION BY category      -- カテゴリごとに独立して番号を振る
      ORDER BY     sales DESC  -- 売上降順で1から番号付け
    ) AS rn
  FROM products
)
SELECT *
FROM   ranked
WHERE  rn <= 2;   -- 各カテゴリの上位2件だけ取る
なぜCTEが必要か:ウィンドウ関数(ROW_NUMBER)の結果は SELECT 句で評価されます。WHERE はそれより前に評価されるため、同じ SELECT 内では ROW_NUMBER の結果を WHERE に使えません。CTEで一度外に出す必要があります。
問題

以下の product_sales テーブルから、カテゴリ(category)ごとに売上(sales)の上位2商品を抽出してください。

同率がある場合も ROW_NUMBER で強制的に2件に絞ること。

使用テーブル
▸ product_sales
product_idcategoryproduct_namesales
P01Foodりんご85000
P02Foodバナナ62000
P03Foodみかん62000
P04Foodぶどう41000
P05Drink緑茶95000
P06Drinkコーヒー78000
P07Drinkジュース78000
P08Drink55000
期待出力
categoryproduct_namesalesrn
Drink緑茶950001
Drinkコーヒー780002
Foodりんご850001
Foodバナナ620002
模範解答コード
WITH ranked_products AS (

  -- ① 全商品に「カテゴリ内の売上順位」を付ける
  SELECT                     -- SELECT: 抽出する列を指定
    category,
    product_name,
    sales,
    ROW_NUMBER() OVER (      -- ROW_NUMBER(): 1から始まる連番を付けるウィンドウ関数
      PARTITION BY category  -- PARTITION BY: カテゴリごとに区切って番号をリセット
      ORDER BY sales DESC, product_id  -- 同額はproduct_id昇順で決定
    ) AS rn                  -- AS rn: row_number の略として別名を定義
  FROM product_sales         -- FROM: 対象テーブル
)                            -- ここでは rn を WHERE に使えない(同一SELECT内でウィンドウ関数結果は参照不可)

-- ② CTE の結果から rn が 2 以下(上位2件)だけを取り出す
SELECT
  category,
  product_name,
  sales,
  rn
FROM   ranked_products  -- FROM: 上で作ったCTEを参照
WHERE  rn <= 2            -- WHERE: 各カテゴリで順位が2以下(1位・2位)のみ残す
ORDER BY category, rn;  -- ORDER BY: カテゴリの昇順 → 順位の昇順で並べる

/*
  実行順序:
  1. CTE内 FROM product_sales      → 全商品行を読み込み
  2. CTE内 ROW_NUMBER() OVER(...)  → カテゴリごとに売上降順で番号付与
  3. 外側 FROM ranked_products      → CTE結果を参照
  4. 外側 WHERE rn <= 2             → 各カテゴリの上位2件を残す
  5. 外側 ORDER BY category, rn     → 整列して出力
  */
解説(テーブル変化・ポイント)
WITH ranked_products AS ( SELECT category, product_name, sales, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY sales DESC, product_id ) AS rn FROM product_sales ) SELECT category, product_name, sales, rn FROM ranked_products WHERE rn <= 2 ORDER BY category, rn;
LEGEND
データ取得・読込対象
① FROM
FROM product_salesproduct_sales テーブル全 8行を読み込みます。CTE ranked_products の入力データです。
1 / 4
product_idcategoryproduct_namesales
P01Foodりんご85,000
P02Foodバナナ62,000
P03Foodみかん62,000
P04Foodぶどう41,000
P05Drink緑茶95,000
P06Drinkコーヒー78,000
P07Drinkジュース78,000
P08Drink55,000
全 8行 読込
学習ポイント
「上位N件API」の基本形:ECの「カテゴリ別人気商品ランキング」「地域別売上トップ3店舗」など、カテゴリ×トップN の取得はどのシステムでも現れるパターンです。CTE+ROW_NUMBERの型を覚えると汎用的に使えます。
SQLの評価順序:FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY の順に評価されます。ウィンドウ関数はSELECT相当のタイミングなのでWHEREより後です。これが「CTEで一度外に出す必要がある」理由です。
アンチパターン
CTEなしでROW_NUMBERをWHEREに使う:SELECT *, ROW_NUMBER() OVER(...) AS rn FROM t WHERE rn <= 2 はエラー。必ずCTEかサブクエリを経由してください。
実務コラム
「各ジャンルの人気ランキング」API:トップN抽出は、ECサイトのトップページ等で「カテゴリ別おすすめ商品」を表示する際によく使われます。ユーザーからのリクエストのたびに全商品のROW_NUMBERを計算するのは非効率なため、実売上ではなく「直近1週間の売上」に絞ってCTE内で対象データを減らしておくなど、パフォーマンスチューニングが腕の見せ所になります。
QUESTION 4

CTE + LAG — 前月比(MoM成長率)を計算する

LAGCTE前月比成長率時系列API
前提知識

LAG(col, n) はウィンドウ関数で、現在行から n 行前の値を返します。前月比・前年同月比・前日比など「時系列の比較」に必須です。

LAG(amount, 1) OVER (ORDER BY order_month)
-- order_month 順に並べたとき、1行前の amount を返す
-- 最初の行(前の行がない場合)は NULL になる

前月比の計算式:(今月 − 前月)÷ 前月 × 100。前月がNULLまたは0の場合はゼロ除算が発生するため NULLIF で防ぎます。

LAGの第3引数(デフォルト値):LAG(col, 1, 0) のように書くと、NULLの代わりに 0 を返せます。APIのレスポンスで NULL を避けたい場合に使います。
逆に次の行を参照する LEAD もあります。
問題

以下の monthly_sales テーブルから、各月の売上・前月売上・前月比(MoM成長率 %)を計算するAPIを作成してください。

前月がない最初の月は prev_sales を NULL、mom_rate も NULL として出力してください。

使用テーブル
▸ monthly_sales
order_monthsales
2024-01100000
2024-02120000
2024-03108000
2024-04135000
2024-05135000
2024-06150000
期待出力
order_monthsalesprev_salesmom_rate
2024-01100000NULLNULL
2024-02120000100000+20.0
2024-03108000120000-10.0
2024-04135000108000+25.0
2024-051350001350000.0
2024-06150000135000+11.1
模範解答コード
WITH sales_with_lag AS (

  -- ① LAG で「1行前の売上」を各行に追加する
  SELECT
    order_month,
    sales,
    LAG(sales, 1) OVER (    -- LAG(列, n): n行前の値を取得(なければNULL)
      ORDER BY order_month  -- ORDER BY: 月の昇順で並べ、何が「前」かを定義する
    ) AS prev_sales         -- AS: 列に名前をつける
  FROM monthly_sales        -- FROM: データ取得元テーブル

)

-- ② CTE を使って mom_rate(前月比)を計算する
SELECT
  order_month,
  sales,
  prev_sales,
  ROUND(                     -- ROUND(値, 桁数): 指定した桁数で四捨五入
    (sales - prev_sales)     -- 今月と前月の差分(増加分)
    * 100.0                  -- 100.0を掛けて%化(整数同士の除算を浮動小数点に変換)
    / NULLIF(prev_sales, 0)  -- NULLIF(a,b): aがbなら NULLを返す(0で割るエラーを防ぐ)
  , 1)                       -- 第2引数: 小数点以下1桁に丸める
  AS mom_rate                -- 計算結果に mom_rate という名前をつける
FROM     sales_with_lag      -- FROM: 上で作ったCTEを参照
ORDER BY order_month;        -- ORDER BY: 月の昇順で並べる

/*
  実行順序:
  1. CTE内 FROM monthly_sales      → 全行読み込み
  2. CTE内 LAG(sales,1) OVER(...)  → 前月の sales を付与
  3. 外側 FROM sales_with_lag       → CTE結果を参照
  4. 外側 ROUND(...)                → 前月比を計算
  5. 外側 ORDER BY order_month      → 月順で並べる
  */
解説(テーブル変化・ポイント)
WITH sales_with_lag AS ( SELECT order_month, sales, LAG(sales, 1) OVER ( ORDER BY order_month ) AS prev_sales FROM monthly_sales ) SELECT order_month, sales, prev_sales, ROUND( (sales - prev_sales) * 100.0 / NULLIF(prev_sales, 0) , 1) AS mom_rate FROM sales_with_lag ORDER BY order_month;
LEGEND
データ取得・読込対象
① FROM
FROM monthly_salesmonthly_sales テーブル全 6行を読み込みます。CTE sales_with_lag の入力データです。
1 / 3
order_monthsales
2024-01100,000
2024-02120,000
2024-03108,000
2024-04135,000
2024-05135,000
2024-06150,000
全 6行 読込
学習ポイント
LAGの実務用途:売上の前月比、ユーザー数の前週比、株価の前日比など時系列分析で必須。ダッシュボードAPIで「矢印↑↓」を表示するデータを返す際に使います。
CTEに切り出す理由:LAGで計算した prev_sales を使って mom_rate を計算するには、同じSELECT内でLAG結果の別名を参照できないため、CTEで先に prev_sales を確定してから次のSELECTで使います。
アンチパターン
ORDER BY なしで LAG を使う:LAGはORDER BYで「前」を定義します。ORDER BYがないと結果が不安定です。必ず指定してください。
整数同士の除算:(sales - prev_sales) / prev_sales は整数÷整数 = 整数(切り捨て)になりゼロが返ります。* 100.0 で先に浮動小数点に変換するのが必須です。
実務コラム
時系列推移と前月比の可視化:MoM(月次成長率)やYoY(年次成長率)の計算は、BIツールやダッシュボードAPIの裏側で常に動いています。フロントエンドのグラフで「前月比↑15%」といった表示をするための必須データです。実務では、「前月が売上0だった場合(ゼロ除算)」や「前月が存在しない場合(NULL)」のエッジケースをいかに安全にハンドリングするかが品質を左右します。
QUESTION 5

再帰CTE — 組織階層(上司→部下)を全レベル展開する

再帰CTERECURSIVE階層クエリ組織APIWITH RECURSIVE
前提知識

再帰CTE(Recursive CTE)は WITH RECURSIVE を使い、自分自身を参照することで階層構造を反復処理できます。組織図・カテゴリ階層・BOM(部品展開)などに使います。

WITH RECURSIVE org_tree AS (
  -- ① アンカーメンバー: 再帰の起点(トップの行を選ぶ)
  SELECT id, name, manager_id, 0 AS depth
  FROM   employees
  WHERE  manager_id IS NULL    -- 上司がいない = トップ

  UNION ALL                    -- アンカーと再帰部を縦結合(重複含む)

  -- ② 再帰メンバー: 前ステップの結果に部下を追加し続ける
  SELECT e.id, e.name, e.manager_id, t.depth + 1
  FROM   employees e
  JOIN   org_tree   t ON e.manager_id = t.id
)
SELECT * FROM org_tree;
再帰の終了条件:再帰メンバーが0行を返した時点で終了します。無限ループ防止のため WHERE depth < 10 のような深さ制限を実務では入れることを推奨します。
問題

以下の employees テーブル(自己参照テーブル)から、社長(manager_idがNULL)を起点に全従業員の階層を展開してください。

各従業員について depth(階層深さを:社長=0)と path(例: "社長→部長→係長")も出力してください。

使用テーブル
▸ employees
emp_idnamemanager_id
1田中(社長)NULL
2鈴木(営業部長)1
3佐藤(技術部長)1
4山田(営業係長)2
5伊藤(営業担当)4
6渡辺(技術リード)3
期待出力
emp_idnamemanager_iddepthpath
1田中(社長)NULL0田中(社長)
2鈴木(営業部長)11田中(社長)→鈴木(営業部長)
3佐藤(技術部長)11田中(社長)→佐藤(技術部長)
4山田(営業係長)22田中(社長)→鈴木(営業部長)→山田(営業係長)
6渡辺(技術リード)32田中(社長)→佐藤(技術部長)→渡辺(技術リード)
5伊藤(営業担当)43田中(社長)→鈴木(営業部長)→山田(営業係長)→伊藤(営業担当)
模範解答コード
-- 再帰CTEには WITH RECURSIVE を使う(PostgreSQL/BigQuery共通)
WITH RECURSIVE org_tree AS (

  -- ① アンカーメンバー: 再帰の「起点」となる行(社長)を選ぶ
  SELECT                    -- SELECT: 取得する列
    emp_id,
    name,
    manager_id,
    0    AS depth,          -- 起点の階層深さを0とする(ASで列名付与)
    name AS path            -- pathの初期値は自分の名前のみ
  FROM  employees           -- FROM: データ元
  WHERE manager_id IS NULL  -- WHERE ... IS NULL: 値が存在しない(上司がいない = トップ)行に絞る

  UNION ALL  -- UNION ALL: 複数のSELECT結果を縦にそのまま結合(重複含む)

  -- ② 再帰メンバー: org_tree の結果に「部下」を追加し続ける
  SELECT
    e.emp_id,
    e.name,
    e.manager_id,
    t.depth + 1,                      -- 親の階層(depth)に +1 して1段深くする
    t.path || '→' || e.name           -- ||: 文字列同士を連結する演算子(PostgreSQL等)
  FROM       employees  e             -- FROM: e は部下側(次の階層)
  INNER JOIN org_tree   t             -- INNER JOIN: t は前ステップで確定した親の行
          ON e.manager_id = t.emp_id  -- ON: 「部下の上司ID = 親のemp_id」という条件で結合
  WHERE t.depth < 10                   -- WHERE: 無限ループを防止するため、最大10階層までに制限

)

SELECT
  emp_id,
  name,
  manager_id,
  depth,
  path
FROM     org_tree        -- FROM: 完成した再帰CTEを参照
ORDER BY depth, emp_id;  -- ORDER BY: 階層(深さ)の昇順 → emp_idの昇順で並べる

/*
  実行順序(再帰の反復):
  1. アンカー                  → 起点行を生成(社長)
  2. 再帰1回目                 → 子を展開
  3. 再帰2回目                 → 子を展開
  4. 再帰3回目                 → 子を展開
  5. 再帰4回目                 → 追加行なしで終了
  6. UNION ALL → ORDER BY  → 縦積みして整列
  */
解説(テーブル変化・ポイント)
WITH RECURSIVE org_tree AS ( SELECT emp_id, name, manager_id, 0 AS depth, name AS path FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.emp_id, e.name, e.manager_id, t.depth + 1, t.path || '→' || e.name FROM employees e INNER JOIN org_tree t ON e.manager_id = t.emp_id WHERE t.depth < 10 ) SELECT emp_id, name, manager_id, depth, path FROM org_tree ORDER BY depth, emp_id;
LEGEND
データ取得・読込対象
⓪ FROM
FROM employeesemployees テーブル全 6行です。これが自己参照して組織ツリーを作っていく元データになります。
1 / 4
emp_idnamemanager_id
1田中(社長)NULL
2鈴木(営業部長)1
3佐藤(技術部長)1
4山田(営業係長)2
5伊藤(営業担当)4
6渡辺(技術リード)3
全 6行 読込
学習ポイント
再帰CTEの実務用途:組織階層API・ECサイトのカテゴリツリーAPI・フォルダ構造・製品BOM(部品表)展開など、階層構造を扱うシステムで必須です。通常のJOINでは「何段階あるか分からない」ケースに対応できません。
アンカーと再帰メンバーの役割:アンカーは「一番最初の種」、再帰メンバーは「種から次の芽を生やす処理」です。芽が出なくなった時点(JOIN 0行)で終了します。
アンチパターン
深さ制限なしで循環参照データに使う:A→B→A のような循環があると無限ループになります。WHERE depth < N で必ず上限を設けてください。
実務コラム
階層データのツリー構造化:再帰CTEは組織図だけでなく、スレッド式のコメント機能(親コメントに対する返信のツリー)や、ファイルシステムのディレクトリ構造を取得するAPIなどで大活躍します。注意点として、実データの入力ミス等により「Aさんの上司がBさん、Bさんの上司がAさん」という循環参照が起こると無限ループに陥りDBのリソースを枯渇させます。そのため、WHERE depth < 10 のような深さのフェールセーフ(安全装置)を必ず入れるのがプロの鉄則です。