【SQL】WITH句(CTE)完全ガイド|複数CTE・UPDATE/DELETE・WITH RECURSIVE・連番/カレンダー生成・パフォーマンスまで解説

【SQL】WITH句を使って副問合せを再利用する方法 SQL
TypeScriptのtsconfig.jsonを完全ガイド。compilerOptionsの型チェック・モジュール・出力オプションを全解説。strict、module、target、paths等の設定をコード例付きで解説し、Webフロント・Node.js・React・Next.js・ライブラリ開発・monorepo向けの実践的な設定テンプレートも紹介。

SQLのWITH句(共通テーブル式・CTE: Common Table Expression)は、クエリ内で名前付きの一時的な結果セットを定義する構文です。複雑なサブクエリをネストして書く代わりに、ステップごとに名前をつけてロジックを整理できます。

基本の再利用・可読性向上にとどまらず、複数CTEの依存関係を使ったパイプライン処理CTEを使ったUPDATE/DELETEWITH RECURSIVEで階層・連番・カレンダーを生成するなど、応用の幅は非常に広いです。本記事でCTEの全機能を習得しましょう。

この記事で学べること:WITH句(CTE)の基本構文・サブクエリとの違い・複数CTEと依存関係・CTEを使ったUPDATE/DELETE(DBMS別)・WITH RECURSIVEの仕組みと無限ループ防止・再帰CTEで連番・カレンダー・階層・BOMを生成するパターン・マテリアライズドCTEとパフォーマンス注意点・DBMS別サポート状況
スポンサーリンク

基本構文

WITH句(CTE)の基本構文
-- 基本形: WITH句で名前付き結果セットを定義
WITH cte_name AS (
    SELECT ...
    FROM   ...
    WHERE  ...
)
SELECT *
FROM   cte_name
WHERE  ...;

-- 複数CTE: カンマ区切りで連続定義
WITH
    first_cte AS (
        SELECT ...
    ),
    second_cte AS (
        SELECT ...
        FROM   first_cte    -- 前に定義したCTEを参照可能
        WHERE  ...
    )
SELECT *
FROM   second_cte;
実例: 部門別の平均給与と比較
-- サブクエリを使うと読みにくい例
SELECT e.name, e.salary,
       (SELECT AVG(salary) FROM employees e2 WHERE e2.dept_id = e.dept_id) AS dept_avg
FROM employees e;

-- CTEを使うとロジックが分かれて読みやすい
WITH dept_avg AS (
    SELECT dept_id, AVG(salary) AS avg_salary
    FROM   employees
    GROUP BY dept_id
)
SELECT
    e.name,
    e.salary,
    d.avg_salary,
    e.salary - d.avg_salary AS diff_from_avg
FROM   employees e
JOIN   dept_avg  d ON e.dept_id = d.dept_id
ORDER BY diff_from_avg DESC;

CTE vs サブクエリ:何が違うのか

観点 CTE(WITH句) サブクエリ(インラインビュー)
書く場所 クエリの冒頭(SELECT/INSERT/UPDATE/DELETE前) FROM句・WHERE句・SELECT句などに埋め込む
再利用 ○ 同じCTE名を複数箇所で参照できる × 再利用不可(毎回コピーして書く)
可読性 ◎ ロジックをステップ別に命名できる △ ネストが深くなると読みにくい
再帰クエリ ○ WITH RECURSIVE が使える × 再帰不可
DML(UPDATE/DELETE)への使用 ○ CTE + UPDATE/DELETE が書ける △ 構文制限あり(DBMS依存)
パフォーマンス DBMSによってはサブクエリと同等に最適化される 直接最適化される(DB次第)
CTEを使うべき場面:①同じサブクエリを複数箇所で参照する・②複雑なロジックをステップ別に分解したい・③WITH RECURSIVEで階層データや連番を生成したい・④CTEを使ったUPDATE/DELETEを書きたい場面ではCTEが優位です。

複数CTEと依存関係

WITH句には複数のCTEをカンマで連続定義できます。後のCTEは前のCTEを参照できますが、前のCTEは後のCTEを参照できません(前方参照のみ)。これを利用してデータ変換を段階的にパイプライン処理できます。

複数CTEのパイプライン処理
WITH
    -- Step1: 直近90日の注文を絞り込む
    recent_orders AS (
        SELECT order_id, customer_id, amount, order_date
        FROM   orders
        WHERE  order_date >= CURRENT_DATE - INTERVAL '90 days'  -- PostgreSQL
        -- WHERE order_date >= DATE_SUB(NOW(), INTERVAL 90 DAY) -- MySQL
    ),
    -- Step2: 顧客ごとの集計(Step1の結果を使う)
    customer_summary AS (
        SELECT
            customer_id,
            COUNT(*)    AS order_count,
            SUM(amount) AS total_amount
        FROM   recent_orders    -- ← Step1のCTEを参照
        GROUP BY customer_id
    ),
    -- Step3: 優良顧客に絞り込む(Step2の結果を使う)
    top_customers AS (
        SELECT customer_id, order_count, total_amount
        FROM   customer_summary  -- ← Step2のCTEを参照
        WHERE  total_amount >= 50000
    )
-- 最終的なSELECT(Step3の結果に顧客マスタをJOIN)
SELECT
    c.customer_name,
    t.order_count,
    t.total_amount
FROM   top_customers t
JOIN   customers c ON t.customer_id = c.customer_id
ORDER BY t.total_amount DESC;
CTEのパイプライン処理はデバッグが簡単:複雑なクエリをCTEで段階分割すると、どのステップまで正しく動いているかをCTEを個別に実行することで確認できます。サブクエリのネストとは違い、中間結果を段階ごとにSELECTできるのが大きな利点です。

CTEを使ったUPDATE / DELETE

CTEはSELECTだけでなく、UPDATE・DELETE文と組み合わせることもできます。集計した値でUPDATEしたり、条件に合う行を特定してから削除する場合に役立ちます。

CTE + UPDATE(PostgreSQL / SQL Server)
-- PostgreSQL / SQL Server: WITH句をUPDATEの前に置く
WITH order_totals AS (
    SELECT customer_id, SUM(amount) AS total
    FROM   orders
    GROUP BY customer_id
)
UPDATE customers c
SET    total_purchase = ot.total
FROM   order_totals ot
WHERE  c.customer_id = ot.customer_id;

-- SQL Server も同じ構文でOK
WITH avg_by_dept AS (
    SELECT dept_id, AVG(salary) AS avg_sal
    FROM   employees
    GROUP BY dept_id
)
UPDATE e
SET    bonus = CASE WHEN e.salary > a.avg_sal THEN e.salary * 0.1 ELSE 0 END
FROM   employees e
JOIN   avg_by_dept a ON e.dept_id = a.dept_id;
CTE + UPDATE(MySQL)
-- MySQL: WITH句はUPDATE/DELETEにも使える(MySQL 8.0+)
WITH inactive_customers AS (
    SELECT customer_id
    FROM   customers
    WHERE  last_login_date < DATE_SUB(NOW(), INTERVAL 365 DAY)
)
UPDATE customers
SET    status = 'inactive'
WHERE  customer_id IN (SELECT customer_id FROM inactive_customers);
CTE + UPDATE(Oracle)
-- Oracle: MERGE 文または インラインビューUPDATE が主流
-- CTEをインラインビューとして使うUPDATE
UPDATE (
    SELECT e.salary, a.avg_sal
    FROM   employees e
    JOIN  (SELECT dept_id, AVG(salary) AS avg_sal FROM employees GROUP BY dept_id) a
           ON e.dept_id = a.dept_id
)
SET salary = salary * 1.05
WHERE salary < avg_sal;

-- Oracle 12c以降: WITH句 + MERGE が可能
WITH high_earners AS (
    SELECT employee_id FROM employees WHERE salary > 100000
)
MERGE INTO bonuses b
USING high_earners h ON (b.employee_id = h.employee_id)
WHEN MATCHED THEN UPDATE SET b.amount = b.amount * 1.2
WHEN NOT MATCHED THEN INSERT (employee_id, amount) VALUES (h.employee_id, 10000);
CTE + DELETE(PostgreSQL / SQL Server)
-- 重複行のうち最小IDを残して他を削除(PostgreSQL)
WITH duplicates AS (
    SELECT id,
           ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn
    FROM   users
)
DELETE FROM users
WHERE  id IN (SELECT id FROM duplicates WHERE rn > 1);

-- SQL Server も同様の構文
WITH ranked AS (
    SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn
    FROM   users
)
DELETE FROM ranked WHERE rn > 1;   -- SQL Server独自: CTEから直接DELETE
OracleとMySQLのCTE+UPDATE注意:OracleはCTEをUPDATEのFROM句に直接使う構文をサポートしていません。MERGE文またはインラインビューを使ってください。MySQLでは8.0以降でCTE+UPDATE/DELETEが使えますが、CTEをサブクエリとして使う(IN句内)形式が安定しています。

WITH RECURSIVE:再帰クエリの仕組み

WITH RECURSIVEを使うと、自分自身を参照する再帰的なクエリが書けます。階層データの展開・連番・カレンダー生成・ツリー構造のパス計算などに使います。

WITH RECURSIVEの基本構造
WITH RECURSIVE cte_name AS (
    -- (1) アンカーメンバー: 再帰のスタート地点(1回だけ実行)
    SELECT 初期値
    FROM   ...
    WHERE  開始条件

    UNION ALL   -- UNION ALL を使う(UNIONは重複排除コストが高い)

    -- (2) 再帰メンバー: cte_nameを参照して繰り返す(終了条件まで)
    SELECT ...
    FROM   ...
    JOIN   cte_name ON 結合条件   -- ← 自分自身を参照
    WHERE  終了条件              -- ← ここでFALSEになったら停止
)
SELECT * FROM cte_name;

再帰の処理フロー

WITH RECURSIVEの処理順:① アンカーメンバーを1回実行 → 「作業テーブル」に格納
② 再帰メンバーで作業テーブルの最後の行から次の行を生成
③ 生成した行を作業テーブルに追加
④ WHERE条件がFALSEになるか、行が0件になったら停止
⑤ 全行をUNION ALLして最終結果を返す

WITH RECURSIVEの実務パターン

パターン1: 組織階層(上司・部下ツリー)

組織階層を再帰で展開
-- サンプル: employees (employee_id, name, manager_id)
-- manager_id = NULL がトップ(社長)

WITH RECURSIVE org_tree AS (
    -- アンカー: トップから開始
    SELECT
        employee_id,
        name,
        manager_id,
        0     AS depth,          -- 階層の深さ
        name  AS path            -- 組織パス
    FROM employees
    WHERE manager_id IS NULL

    UNION ALL

    -- 再帰: 直属の部下を取得
    SELECT
        e.employee_id,
        e.name,
        e.manager_id,
        ot.depth + 1,
        ot.path || ' > ' || e.name    -- PostgreSQL: || で文字列結合
        -- ot.path + ' > ' + e.name   -- SQL Server
        -- CONCAT(ot.path, ' > ', e.name)  -- MySQL
    FROM employees e
    JOIN org_tree ot ON e.manager_id = ot.employee_id
)
SELECT
    REPEAT('  ', depth) || name AS indented_name,  -- 階層インデント
    path,
    depth
FROM org_tree
ORDER BY path;

パターン2: 連番の生成

1〜100の連番を生成(CTEで数列テーブル代替)
-- MySQL / PostgreSQL / SQL Server
WITH RECURSIVE numbers AS (
    SELECT 1 AS n         -- アンカー: 1からスタート
    UNION ALL
    SELECT n + 1          -- 再帰: +1ずつ増やす
    FROM   numbers
    WHERE  n < 100        -- 終了条件: 100まで
)
SELECT n FROM numbers;

-- Oracle: CONNECT BY LEVELが簡潔(Oracle独自)
SELECT LEVEL AS n
FROM   DUAL
CONNECT BY LEVEL <= 100;

パターン3: カレンダー(日付シーケンス)の生成

指定期間の全日付を生成
-- 2024-01-01 〜 2024-03-31 の全日付を生成(MySQL)
WITH RECURSIVE calendar AS (
    SELECT DATE('2024-01-01') AS dt
    UNION ALL
    SELECT DATE_ADD(dt, INTERVAL 1 DAY)
    FROM   calendar
    WHERE  dt < DATE('2024-03-31')
)
SELECT dt, DAYNAME(dt) AS day_name   -- MySQL
FROM   calendar;

-- PostgreSQL
WITH RECURSIVE calendar AS (
    SELECT DATE '2024-01-01' AS dt
    UNION ALL
    SELECT dt + INTERVAL '1 day'
    FROM   calendar
    WHERE  dt < DATE '2024-03-31'
)
SELECT dt, TO_CHAR(dt, 'Day') AS day_name
FROM   calendar;

-- 活用例: カレンダー LEFT JOIN で実績がない日も0件で表示
SELECT
    c.dt,
    COALESCE(SUM(s.amount), 0) AS daily_sales
FROM   calendar c
LEFT JOIN sales s ON s.sale_date = c.dt
GROUP BY c.dt
ORDER BY c.dt;

パターン4: BOM(部品表)展開

BOM(Bill of Materials)の多段階展開
-- parts(part_id, part_name)
-- bom(parent_id, child_id, quantity) -- 親部品→子部品

WITH RECURSIVE bom_tree AS (
    -- アンカー: 完成品(親なし)からスタート
    SELECT
        b.parent_id,
        b.child_id,
        b.quantity,
        1           AS level,
        b.quantity  AS total_qty   -- 累積数量
    FROM bom b
    WHERE b.parent_id = 1          -- 製品ID=1の部品表

    UNION ALL

    -- 再帰: さらにその子部品を展開
    SELECT
        b.parent_id,
        b.child_id,
        b.quantity,
        bt.level + 1,
        bt.total_qty * b.quantity   -- 上位階層の数量を掛け合わせ
    FROM bom b
    JOIN bom_tree bt ON b.parent_id = bt.child_id
)
SELECT
    bt.level,
    p.part_name,
    bt.total_qty
FROM bom_tree bt
JOIN parts p ON bt.child_id = p.part_id
ORDER BY bt.level, p.part_name;
無限ループに注意:再帰CTEの終了条件が不適切だと無限ループになります。DBMSはmax_recursion_depth(MySQL: 1000)やMAXRECURSION(SQL Server: 100デフォルト)で最大再帰深さを制限しています。実際のデータに循環参照がある場合(A→B→A)は別途チェックが必要です。
無限ループを防ぐ:深さ制限と訪問済みチェック
-- 深さ制限(安全策として必ず入れる)
WITH RECURSIVE safe_tree AS (
    SELECT employee_id, manager_id, 1 AS depth
    FROM   employees WHERE manager_id IS NULL
    UNION ALL
    SELECT e.employee_id, e.manager_id, st.depth + 1
    FROM   employees e
    JOIN   safe_tree st ON e.manager_id = st.employee_id
    WHERE  st.depth < 20   -- ← 最大20階層まで(安全策)
)
SELECT * FROM safe_tree;

-- 訪問済みIDを配列で追跡(循環参照チェック)- PostgreSQL
WITH RECURSIVE path_check AS (
    SELECT employee_id, ARRAY[employee_id] AS visited
    FROM   employees WHERE manager_id IS NULL
    UNION ALL
    SELECT e.employee_id, pc.visited || e.employee_id
    FROM   employees e
    JOIN   path_check pc ON e.manager_id = pc.employee_id
    WHERE  NOT (e.employee_id = ANY(pc.visited))  -- 訪問済みなら停止
)
SELECT * FROM path_check;

DBMSのサポート状況

DBMS CTE(WITH句) WITH RECURSIVE CTE + UPDATE/DELETE
MySQL ○(8.0以降) ○(8.0以降) ○(8.0以降、一部制限あり)
PostgreSQL ○(8.4以降) ○(8.4以降)
Oracle ○(9i以降) ○(11g以降) △(MERGE文またはインラインビューで対応)
SQL Server ○(2005以降) ○(2005以降)
MySQL 5.7以前はCTE非対応:MySQL 5.7以前ではWITH句が使えません。バージョンを SELECT VERSION(); で確認してください。5.7以前の場合はサブクエリ(インラインビュー)または一時テーブルで代替します。

マテリアライズドCTEとパフォーマンスの注意点

CTEはDBMSによって「実体化(マテリアライズ)」されるかどうかが異なります。

挙動 説明 メリット デメリット
マテリアライズ(実体化) CTEを一時的に評価・格納してから参照 複数参照でも1回だけ実行 大量データのCTEがメモリを消費
インライン化(最適化合流) CTEを展開してメインクエリに合流 オプティマイザが最適化できる 複数参照の場合は毎回実行される可能性
Oracle: MATERIALIZE / NO_MATERIALIZEヒント
-- Oracle: CTEを強制的にマテリアライズ(一時ストアに格納)
WITH /*+ MATERIALIZE */ expensive_cte AS (
    SELECT customer_id, SUM(amount) AS total
    FROM   orders
    GROUP BY customer_id
)
SELECT c.name, e.total
FROM   customers c
JOIN   expensive_cte e ON c.customer_id = e.customer_id;

-- Oracle: インライン化を強制(マテリアライズを避ける)
WITH /*+ INLINE */ simple_cte AS (
    SELECT * FROM customers WHERE active = 1
)
SELECT * FROM simple_cte;
PostgreSQL: MATERIALIZEDオプション(PostgreSQL 12+)
-- PostgreSQL 12以降: CTEのマテリアライズを明示指定
WITH MATERIALIZED expensive_cte AS (
    SELECT customer_id, SUM(amount) AS total
    FROM   orders
    GROUP BY customer_id
)
SELECT * FROM expensive_cte;

-- マテリアライズを禁止(インライン化を強制)
WITH NOT MATERIALIZED simple_cte AS (
    SELECT * FROM customers WHERE active = 1
)
SELECT * FROM simple_cte;
CTEのパフォーマンス判断:①同じCTEを複数箇所で参照する場合 → マテリアライズが有利(1回だけ実行)
②CTEを1か所でしか参照しない場合 → インライン化が有利(オプティマイザが最適化)
③大量データのCTEを毎回再計算させたくない → MATERIALIZEDを指定(PostgreSQL 12+ / Oracle)
判断に迷ったらEXPLAINで実行計画を確認してください。

実務でよく使うCTEパターン集

ランキング(各グループの上位N件)

部門別上位3名の給与を取得
WITH ranked_employees AS (
    SELECT
        employee_id,
        name,
        dept_id,
        salary,
        RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS dept_rank
    FROM employees
)
SELECT employee_id, name, dept_id, salary, dept_rank
FROM   ranked_employees
WHERE  dept_rank <= 3   -- 各部門の上位3名
ORDER BY dept_id, dept_rank;

前期比較(前月・前年との差分)

月次売上の前月比較
WITH monthly_sales AS (
    SELECT
        DATE_FORMAT(order_date, '%Y-%m') AS month,   -- MySQL
        SUM(amount)                      AS total
    FROM   orders
    GROUP BY month
),
with_prev AS (
    SELECT
        month,
        total,
        LAG(total) OVER (ORDER BY month) AS prev_total
    FROM   monthly_sales
)
SELECT
    month,
    total,
    prev_total,
    total - prev_total                              AS diff,
    ROUND(100.0 * (total - prev_total) / prev_total, 1) AS growth_pct
FROM   with_prev
WHERE  prev_total IS NOT NULL
ORDER BY month;

データ品質チェック(複数条件を段階的に評価)

不正データの段階的チェック
WITH
    -- Step1: 対象データを絞り込む
    target_records AS (
        SELECT * FROM customers WHERE created_at >= '2024-01-01'
    ),
    -- Step2: 各種バリデーション
    validation_results AS (
        SELECT
            customer_id,
            name,
            email,
            phone,
            CASE WHEN email IS NULL OR email = '' THEN 1 ELSE 0 END AS no_email,
            CASE WHEN phone IS NULL OR phone = '' THEN 1 ELSE 0 END AS no_phone,
            CASE WHEN LENGTH(name) < 2              THEN 1 ELSE 0 END AS short_name
        FROM target_records
    ),
    -- Step3: 1件以上のエラーがある行だけ
    error_records AS (
        SELECT * FROM validation_results
        WHERE no_email + no_phone + short_name > 0
    )
SELECT
    customer_id,
    name,
    CASE WHEN no_email  = 1 THEN 'メール未登録 ' ELSE '' END ||
    CASE WHEN no_phone  = 1 THEN '電話未登録 '   ELSE '' END ||
    CASE WHEN short_name= 1 THEN '名前が短すぎ ' ELSE '' END AS error_detail
FROM error_records
ORDER BY customer_id;

よくある質問

QCTEと一時テーブル(CREATE TEMP TABLE)の違いは何ですか?
ACTEはクエリ内だけで有効な一時的な定義です。クエリが終わると消えます。一時テーブルはセッション中(またはトランザクション中)保持され、複数クエリからアクセスできます。単一クエリ内の整理・再利用ならCTE、複数クエリ間でデータを共有する場合は一時テーブルを使います。
QMySQLでWITH句が使えない(エラーになる)のはなぜですか?
AWITH句(CTE)はMySQL 8.0以降でサポートされました。SELECT VERSION(); でバージョンを確認してください。5.7以前の場合はサブクエリ(FROM (SELECT ...) AS sub)または一時テーブルで代替します。
QWITH RECURSIVEで最大再帰数を超えるエラーが出ます
ADBMSのデフォルト最大再帰深さに達しています。MySQLはSET SESSION cte_max_recursion_depth = 10000;で増やせます。SQL ServerはOPTION (MAXRECURSION 1000)をクエリ末尾に追加します。まず終了条件のWHERE句が正しく機能しているか確認してください(無限ループでないかチェック)。
QCTEを使うとサブクエリより遅くなることがありますか?
A多くのDBMSではCTEをインライン化してサブクエリと同等に最適化します。ただしPostgreSQL 11以前ではCTEが常にマテリアライズされ(最適化に使えるプッシュダウンが効かず)遅くなる場合がありました。PostgreSQL 12以降はデフォルトでインライン化されます。パフォーマンスが気になる場合はEXPLAIN ANALYZEで確認してください。
QCTEの中でINSERT/UPDATE/DELETEは使えますか?
APostgreSQL・SQL Serverではデータ変更CTEとしてWITH ... AS (INSERT/UPDATE/DELETE ... RETURNING ...)が使えます。削除した行を別テーブルにINSERTする「移動」操作などを1クエリで書けます。MySQLはCTE内でのDMLには対応していません(CTE + UPDATE/DELETE は可能ですが、CTE自体がDMLは不可)。

まとめ

WITH句(CTE)はSQLの可読性・保守性を大幅に向上させる強力な機能です。

  • 基本:クエリ冒頭でWITH名前 AS (SELECT…)を定義し、以降のSELECT/UPDATE/DELETEで参照する
  • 複数CTE:カンマで連続定義し、後のCTEが前のCTEを参照できるパイプライン処理が書ける
  • CTE + UPDATE/DELETE:集計結果でUPDATEや条件指定DELETEが書ける(MySQL 8.0+・PostgreSQL・SQL Server)
  • WITH RECURSIVE:アンカー + 再帰メンバー + 終了条件の構造。組織階層・連番・カレンダー・BOM展開に活用
  • パフォーマンス:マテリアライズかインライン化かはDBMS次第。PostgreSQL 12+ではMATERIALIZEDで明示制御可能
  • サポート状況:MySQL 8.0以降・PostgreSQL 8.4以降・Oracle 9i以降・SQL Server 2005以降で利用可能
CTEの使いどき:①同じサブクエリを2回以上使う → CTEで1か所にまとめる、②複雑なクエリを整理したい → 複数CTEでパイプライン分割、③階層・連番・カレンダーが必要 → WITH RECURSIVE、④集計後にUPDATE/DELETE → CTE + DML構文

関連記事:サブクエリでUPDATE完全ガイド(CTE・MERGE含む)UNIONとORDER BYを同時に使用する方法Oracle 階層問い合わせ(START WITH・CONNECT BY)