CTEと一時テーブル
CTEと一時テーブル
🌍 実世界のアナロジー
複雑な料理を想像してください:
- サブクエリ — 「刻みネギ」が必要になるたびに毎回刻み直す(都度再実行)
- CTE — まずネギを刻んでボウルに入れ、後でいつでも使えるようにする(一度定義し、複数回参照)
- 一時テーブル — 刻んだ食材を一時的な皿に載せる。この料理だけでなく、調理セッション全体を通じて利用可能(セッション内で繰り返しアクセス可能)
CTEと一時テーブルはどちらも「先に準備して後で使う」アプローチで、複雑なクエリを明確かつ整理された状態にします。
🎯 コア概念
WITH句 / CTE
CTE(共通テーブル式)は WITH キーワードを使用して一時的な名前付き結果セットを定義し、現在のクエリ内でテーブルのように参照できます。
WITH cte_name AS (
-- CTEクエリ
SELECT column1, column2 FROM table1
)
-- メインクエリがCTEを参照
SELECT * FROM cte_name;
利点:
- 可読性:複雑なクエリを複数の論理ステップに分割
- 再利用性:同じCTEをメインクエリ内で複数回参照可能
- 再帰:ツリー構造の走査をサポート
再帰的CTE
再帰的CTEは WITH RECURSIVE で定義され、2つの部分からなります:
- アンカーメンバー:初期クエリ、再帰の開始点
- 再帰メンバ:自分自身を参照し、ステップごとに展開
WITH RECURSIVE cte_name AS (
-- アンカー:初期クエリ
SELECT ... FROM table WHERE condition
UNION ALL
-- 再帰:自分自身を参照
SELECT ... FROM table JOIN cte_name ON condition
)
SELECT * FROM cte_name;
ユースケース:組織図、カテゴリディレクトリ、経路探索、その他の階層データ。
CTEとサブクエリの比較
| 特徴 | CTE | サブクエリ |
|---|---|---|
| 可読性 | 優れている(先に名前を付け、後で使用) | 深くネストすると読みにくい |
| 再利用性 | 複数回参照 | 参照されるたびに再実行 |
| 再帰 | ✅ サポート | ❌ 非サポート |
| パフォーマンス | 通常同じ実行計画に最適化される | 同じ |
| スコープ | 現在の文のみ | 現在の文のみ |
一時テーブル — CREATE TEMPORARY TABLE
一時テーブルは現在のデータベースセッション内に存在し、セッション終了時に自動的に削除されます。
CREATE TEMPORARY TABLE temp_result (
id INT,
name VARCHAR(50),
amount DECIMAL(10,2)
);
INSERT INTO temp_result SELECT ...;
SELECT * FROM temp_result;
ユースケース:
- 複数のSQL文で同じ中間結果を共有する場合
- ストアドプロシージャの中間データ
- 中間結果にインデックスを作成する必要がある場合
CTEユースケースまとめ
| シナリオ | 推奨アプローチ |
|---|---|
| 単純なクエリの中間結果 | サブクエリ |
| 複雑なクエリをステップに分割 | CTE |
| ツリー/階層データの走査 | 再帰的CTE |
| 複数のSQL文でデータを共有 | 一時テーブル |
| 中間結果にインデックスが必要 | 一時テーブル |
WITH RECURSIVEによるツリーデータの走査
従業員テーブルの department_id や自己参照の manager_id のようなフィールドは階層関係を形成できます。再帰的CTEで簡単に走査できます:
-- 従業員の全上司を見つける(上方向に走査)
-- 部門の全下位部門を見つける(下方向に走査)
-- 日付シリーズ(数値シリーズ)を生成
📝 基本構文
-- 基本的なCTE
WITH cte_name AS (
SELECT ...
)
SELECT ... FROM cte_name;
-- 複数のCTE
WITH cte1 AS (...),
cte2 AS (...)
SELECT ... FROM cte1 JOIN cte2 ON ...;
-- 再帰的CTE
WITH RECURSIVE cte_name AS (
-- アンカー(開始点)
SELECT ... FROM table WHERE initial_condition
UNION ALL
-- 再帰(自分自身を参照)
SELECT ... FROM table
JOIN cte_name ON join_condition
WHERE termination_condition
)
SELECT * FROM cte_name;
-- 一時テーブル
CREATE TEMPORARY TABLE temp_name AS
SELECT ... FROM ...;
-- または手動で作成
CREATE TEMPORARY TABLE temp_name (
col1 INT,
col2 VARCHAR(50)
);
- CTEは、直後に続く単一の
SELECT/INSERT/UPDATE/DELETE文でのみ使用可能 - 再帰的CTEには終了条件が必要。さもなければ無限ループになる
- 一時テーブルの名前は既存のテーブル名と競合してはならない
- MySQLは
CREATE TEMPORARY TABLE、SQL Serverは#temp_tableを使用
📌 例
例:CTEを使った複雑なクエリの分解 — 部門一人当たり売上分析(難易度⭐⭐)
WITH dept_stats AS (
SELECT
d.department_id,
d.department_name AS department,
COUNT(DISTINCT e.employee_id) AS employee_count,
COUNT(o.order_id) AS order_count,
COALESCE(SUM(o.total_amount), 0) AS total_sales
FROM departments d
LEFT JOIN employees e ON d.department_id = e.department_id
LEFT JOIN orders o ON e.employee_id = o.employee_id AND o.status != 'cancelled'
GROUP BY d.department_id, d.department_name
)
SELECT
department,
employee_count,
order_count,
total_sales,
CASE WHEN employee_count > 0 THEN ROUND(total_sales / employee_count, 2) ELSE 0 END AS sales_per_capita,
CASE WHEN employee_count > 0 THEN ROUND(order_count * 1.0 / employee_count, 2) ELSE 0 END AS orders_per_capita
FROM dept_stats
ORDER BY sales_per_capita DESC;
結果:
department | employee_count | order_count | total_sales | sales_per_capita | orders_per_capita
-----------+----------------+-------------+-------------+------------------+------------------
Sales | 2 | 4 | 15100.00 | 7550.00 | 2.00
Tech | 2 | 3 | 8750.00 | 4375.00 | 1.50
Marketing | 1 | 0 | 0.00 | 0.00 | 0.00
Finance | 0 | 0 | 0.00 | 0.00 | 0.00
説明:dept_stats CTEがまずベースデータを集計し、メインクエリが一人当たりの指標を計算します。論理が明確で保守しやすいです。
例:再帰的CTE — 従業員の管理階層(難易度⭐⭐)
employees テーブルに直属の上司を示す manager_id フィールドがあると仮定します:
-- まずmanager_idカラムとデータを追加
ALTER TABLE employees ADD COLUMN manager_id INT;
UPDATE employees SET manager_id = NULL WHERE employee_id = 1; -- Zhang Wei:社長
UPDATE employees SET manager_id = 1 WHERE employee_id = 2; -- Li Na -> Zhang Wei
UPDATE employees SET manager_id = 1 WHERE employee_id = 3; -- Wang Qiang -> Zhang Wei
UPDATE employees SET manager_id = 3 WHERE employee_id = 4; -- Zhao Min -> Wang Qiang
UPDATE employees SET manager_id = 3 WHERE employee_id = 5; -- Liu Yang -> Wang Qiang
UPDATE employees SET manager_id = NULL WHERE employee_id = 6; -- Chen Jing:上司なし
-- 再帰クエリ:Zhang Weiから始め、全部下を見つける(下方向に走査)
WITH RECURSIVE emp_hierarchy AS (
-- アンカー:開始点(Zhang Wei)
SELECT
employee_id,
CONCAT(first_name, last_name) AS name,
manager_id,
0 AS level,
CAST(CONCAT(first_name, last_name) AS CHAR(500)) AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- 再帰:部下を見つける
SELECT
e.employee_id,
CONCAT(e.first_name, e.last_name),
e.manager_id,
h.level + 1,
CAST(CONCAT(h.path, ' → ', e.first_name, e.last_name) AS CHAR(500))
FROM employees e
INNER JOIN emp_hierarchy h ON e.manager_id = h.employee_id
)
SELECT
CONCAT(REPEAT(' ', level), name) AS org_chart,
level,
path
FROM emp_hierarchy
ORDER BY path;
結果:
org_chart | level | path
---------------+-------+----------------------------
Zhang Wei | 0 | Zhang Wei
Li Na | 1 | Zhang Wei → Li Na
Wang Qiang | 1 | Zhang Wei → Wang Qiang
Zhao Min | 2 | Zhang Wei → Wang Qiang → Zhao Min
Liu Yang | 2 | Zhang Wei → Wang Qiang → Liu Yang
Chen Jing | 0 | Chen Jing
説明:
- アンカーメンバーが「上司なし」の全トップレベル従業員を見つける
- 再帰メンバがレベルごとに部下を見つけ、
level + 1で深さを記録 REPEAT(' ', level)がインデントで階層を視覚的に表示CAST(... AS CHAR(500))が再帰中の文字列切り捨てを防止
🎬 シナリオ演習
シナリオ1:顧客生涯価値をステップごとに計算
WITH order_summary AS (
-- ステップ1:各顧客の注文データを集計
SELECT
customer_id,
MIN(order_date) AS first_purchase,
MAX(order_date) AS last_purchase,
COUNT(order_id) AS order_count,
SUM(total_amount) AS total_spent
FROM orders
WHERE status != 'cancelled'
GROUP BY customer_id
),
customer_ltv AS (
-- ステップ2:生涯日数と支出頻度を計算
SELECT
customer_id,
first_purchase,
last_purchase,
order_count,
total_spent,
DATEDIFF(last_purchase, first_purchase) AS lifetime_days,
CASE
WHEN DATEDIFF(last_purchase, first_purchase) > 0
THEN ROUND(total_spent / (DATEDIFF(last_purchase, first_purchase) / 30.0), 2)
ELSE total_spent
END AS monthly_avg_spent
FROM order_summary
)
SELECT
customer_id,
first_purchase,
last_purchase,
order_count,
total_spent,
lifetime_days,
monthly_avg_spent,
CASE
WHEN monthly_avg_spent >= 3000 THEN '高額顧客'
WHEN monthly_avg_spent >= 1000 THEN '中額顧客'
ELSE '低額顧客'
END AS customer_tier
FROM customer_ltv
ORDER BY total_spent DESC;
ポイント:複数のCTEをチェーンし、各ステップの論理が明確です。まず集計し、次に計算し、最後に分類します。ネストされたサブクエリよりはるかに可読性が高いです。
シナリオ2:日付シリーズを生成して売上トレンド分析
WITH RECURSIVE date_series AS (
-- アンカー:開始日
SELECT CAST('2026-01-01' AS DATE) AS dt
UNION ALL
-- 再帰:毎回1日追加
SELECT DATE_ADD(dt, INTERVAL 1 DAY)
FROM date_series
WHERE dt < '2026-06-30'
)
SELECT
ds.dt AS date,
COALESCE(COUNT(o.order_id), 0) AS order_count,
COALESCE(SUM(o.total_amount), 0) AS sales
FROM date_series ds
LEFT JOIN orders o ON o.order_date = ds.dt AND o.status != 'cancelled'
GROUP BY ds.dt
ORDER BY ds.dt;
ポイント:再帰的CTEが連続的な日付シリーズを生成し、ordersテーブルとのLEFT JOINにより注文がない日も表示されます(値は0)。トレンドチャートの作成に適しています。
❓ よくある質問
質問:CTEと一時テーブルの違いは何ですか? 回答: CTEは現在の文内でのみ有効で、文の終了時に消滅します。一時テーブルはセッション全体で有効で、複数のSQL文から参照できます。CTEは単一の複雑なクエリを分解するのに適し、一時テーブルは中間結果に複数回アクセスする必要があるシナリオに適しています。
質問:再帰的CTEは無限ループになりますか? 回答: はい、適切な終了条件がない場合は無限ループになります。再帰部分に深さまたは範囲を制限する
WHERE条件を必ず含めてください。MySQLにはcte_max_recursion_depthパラメータ(デフォルト1000)があり、無限再帰を防止します。
質問:CTEにインデックスを作成できますか? 回答: いいえ、CTE自体にインデックスを作成することはできません。中間結果にインデックスが必要な場合は、代わりに一時テーブルを使用してください。一時テーブルはインデックスをサポートしています。
質問:複数のCTEは相互に参照できますか? 回答: はい。同じ
WITH句で定義された複数のCTEは、それ以前のCTEを参照できます。ただし、相互参照はできません(CTE AがCTE Bを参照し、CTE BもCTE Aを参照する場合)。
📖 まとめ
| 技術 | スコープ | 再利用可能 | 再帰 | インデックス可能 |
|---|---|---|---|---|
| サブクエリ | 単一文 | ❌ | ❌ | ❌ |
| CTE | 単一文 | ✅ 複数回参照 | ✅ | ❌ |
| 一時テーブル | セッション全体 | ✅ 複数のSQL文 | — | ✅ |
- CTEは
WITHで定義し、複雑なクエリの可読性と保守性を向上 - 再帰的CTEは
WITH RECURSIVEを使用し、ツリーデータの走査やシリーズ生成に適している - 一時テーブルはセッション終了時に自動的に削除され、SQL文間の中間結果の共有に適している
📝 演習
- CTEを使って各部門の最高給与従業員を見つけ、その給与と部門平均の差を表示してください。
- 再帰的CTEを使って1から20までの数値シリーズを生成してください。
- 一時テーブルを使って各顧客の注文サマリーデータを保存し、その一時テーブルに基づいて顧客ティア分析を実行してください。
- 思考問題:CTEがメインクエリから2回参照された場合、2回実行されますか?
EXPLAINで検証してください。
次のレッスン
👉 21-transactions - トランザクション処理:SQLトランザクションのACID特性、BEGIN/COMMIT/ROLLBACK構文、SAVEPOINTセーブポイント、トランザクション分離レベル、デッドロックの概念を学びます。



