SQLクエリが複雑になると、サブクエリが入れ子になって読みにくくなりがちです。WITH句(CTE:共通テーブル式)を使うと、複雑なクエリを名前付きのブロックに分け、上から順に読める形に整えられます。本記事では、CTEの基本構文から複数CTEの連結、サブクエリとの使い分け、MySQL 8.0での注意点まで、実例とともに解説します。以下の結果は MySQL 8.0 で実際に実行したものです。
このシナリオで考える
以下のようなECサイトのテーブルを使って解説を進めます。orders(注文)とorder_items(注文明細)の2テーブルを想定し、「各顧客の注文合計金額を集計して上位顧客を抽出する」という処理を例にします。
CREATE TABLE orders (
order_id INT PRIMARY KEY,
customer_id INT NOT NULL,
ordered_at DATE NOT NULL
);
CREATE TABLE order_items (
item_id INT PRIMARY KEY,
order_id INT NOT NULL,
product_name VARCHAR(100),
price INT NOT NULL,
quantity INT NOT NULL
);
INSERT INTO orders VALUES
(1, 101, '2025-04-01'),
(2, 102, '2025-04-02'),
(3, 101, '2025-04-05'),
(4, 103, '2025-04-06');
INSERT INTO order_items VALUES
(1, 1, 'キーボード', 8000, 1),
(2, 1, 'マウス', 3000, 2),
(3, 2, 'モニター', 30000, 1),
(4, 3, 'キーボード', 8000, 1),
(5, 4, 'マウス', 3000, 1);
このデータを使って、以降の各セクションでCTEの挙動を確認していきます。
WITH句(CTE)の基本構文
CTEは、WITHキーワードに続けて「名前」と「定義クエリ」を書き、メインクエリの前に置く構造です。
WITH cte_name AS (
SELECT ...
)
SELECT * FROM cte_name;
先ほどのテーブルで「各注文の合計金額を計算する」CTEを書いてみましょう。
WITH order_totals AS (
SELECT
order_id,
SUM(price * quantity) AS total_amount
FROM order_items
GROUP BY order_id
)
SELECT
o.order_id,
o.customer_id,
ot.total_amount
FROM orders AS o
JOIN order_totals AS ot ON o.order_id = ot.order_id
ORDER BY o.order_id;
| order_id | customer_id | total_amount |
|---|---|---|
| 1 | 101 | 14000 |
| 2 | 102 | 30000 |
| 3 | 101 | 8000 |
| 4 | 103 | 3000 |
order_totalsという名前で合計金額を先に定義しておき、メインクエリでは単純にJOINするだけです。サブクエリをFROM句の中に直接書くより、何を計算しているかが一目で分かります。
CTEはそのSQL文の中だけで使え、テーブルのように残りはしません。SELECT・INSERT・UPDATE・DELETEのすべてで使えます。
複数のCTEを連結して使う
カンマ(,)で区切ることで、複数のCTEを1つのWITH句に定義できます。後から定義したCTEは、先に定義したCTEを参照できます。処理を「ステップ1、ステップ2」と積み上げる書き方です。
WITH
order_totals AS (
-- ステップ1: 各注文の合計金額
SELECT
order_id,
SUM(price * quantity) AS total_amount
FROM order_items
GROUP BY order_id
),
customer_totals AS (
-- ステップ2: 各顧客の累計金額(order_totals を参照)
SELECT
o.customer_id,
SUM(ot.total_amount) AS grand_total
FROM orders AS o
JOIN order_totals AS ot ON o.order_id = ot.order_id
GROUP BY o.customer_id
)
SELECT
customer_id,
grand_total
FROM customer_totals
ORDER BY grand_total DESC;
| customer_id | grand_total |
|---|---|
| 102 | 30000 |
| 101 | 22000 |
| 103 | 3000 |
order_totals → customer_totalsの順に処理が積み上がり、クエリを上から下へ読むだけで全体の流れを把握できます。
同等の処理をサブクエリのみで書くと、次のように入れ子が深くなります。
-- サブクエリ版(同じ結果)
SELECT
o.customer_id,
SUM(oi_sum.total_amount) AS grand_total
FROM orders AS o
JOIN (
SELECT order_id, SUM(price * quantity) AS total_amount
FROM order_items
GROUP BY order_id
) AS oi_sum ON o.order_id = oi_sum.order_id
GROUP BY o.customer_id
ORDER BY grand_total DESC;
ネストが1段しかない今でも、FROMの直後にサブクエリが入るため、どこからどこまでがサブクエリかを追いながら読む必要があります。処理が増えるほどこの差は広がります。
サブクエリとCTEの使い分け
CTEとインラインサブクエリは多くの場合で同じ結果を返します。どちらを選ぶかは以下の観点で判断しましょう。
| 観点 | CTE | インラインサブクエリ |
|---|---|---|
| 読みやすさ | 高い(処理を分けて名前を付けられる) | 入れ子が深いと読みにくい |
| 再利用 | 同一クエリ内で複数回参照できる | 同じ式を繰り返し書く必要あり |
| デバッグ | CTEごとに単独で実行して確認できる | 内側から切り出して確認が必要 |
| 性能 | オプティマイザの判断に依存(後述) | ほぼ同じ |
特に役立つのは、同じ集計の結果を、さらにウィンドウ関数などで加工する場面です。次の例ではmonthly_salesで月ごとの売上を出し、LAG()で前の月の売上を横に並べています。
WITH monthly_sales AS (
SELECT
DATE_FORMAT(o.ordered_at, '%Y-%m') AS month,
SUM(oi.price * oi.quantity) AS sales
FROM orders AS o
JOIN order_items AS oi ON o.order_id = oi.order_id
GROUP BY month
)
SELECT
month,
sales,
LAG(sales) OVER (ORDER BY month) AS prev_sales
FROM monthly_sales
ORDER BY month;
+---------+-------+------------+
| month | sales | prev_sales |
+---------+-------+------------+
| 2025-04 | 55000 | NULL |
+---------+-------+------------+
サンプルの注文は4月だけなので1行で、前の月がない prev_sales は NULL です。月が増えれば、前月の売上が並びます。前月比の出し方は「LAG・LEAD関数で前日比・前月比を出す方法」で詳しく説明しています。サブクエリで同じことをすると、集計のサブクエリの外側にもう1段サブクエリを重ねることになります。
MySQL 8.0のパフォーマンスについて: CTEを一時テーブルに実体化(マテリアライズ)するか、外側のクエリに展開するかは、オプティマイザが決めます。単純なCTEはサブクエリとほぼ同じ速さです。決め方を変えたいときは、オプティマイザヒントの /*+ NO_MERGE(cte_name) */(実体化する)か /*+ MERGE(cte_name) */(展開する)を SELECT のすぐあとに書きます。PostgreSQL の AS MATERIALIZED にあたる MATERIALIZED というヒントは MySQL にはなく、書いても警告(1064)が出て無視されます。
よくある落とし穴 / 注意点
CTEはVIEWではありません。CTEはそのSQL文の中だけで使え、別のクエリからは参照できません。クエリをまたいで使い回したい場合はVIEWを作成してください。
CTE内のORDER BYで最終的な並び順は決まりません。CTEの内側に書いたORDER BYは、LIMITを付けない限り無視されることがあります。並び順はメインクエリのORDER BYで指定しましょう。
-- NG: CTE内のORDER BYは保証されない
WITH ranked AS (
SELECT * FROM order_items ORDER BY price DESC
)
SELECT * FROM ranked;
-- OK: メインクエリでソートする
WITH ranked AS (
SELECT * FROM order_items
)
SELECT * FROM ranked ORDER BY price DESC;
再帰CTEには深さ制限があります。WITH RECURSIVEを使った再帰処理は、MySQLのデフォルト設定で最大1000回に制限されています(cte_max_recursion_depthで変更可能)。再帰CTEの使い方は「WITH RECURSIVEで階層構造を取得する再帰CTE入門」、連続した日付を作って売上0件の日も出す例は「売上0件の日も表示するSQL:再帰CTEで連続した日付を作る」で説明しています。
また、複数CTEの連結では参照方向に注意が必要です。後のCTEから前のCTEは参照できますが、前のCTEから後のCTEは参照できません。定義の順序が実行の依存関係を決めます。
まとめ
WITH句(CTE)は、複雑なSQLを処理のまとまりごとに名前を付けて分けられる構文です。読みやすさ・確かめやすさ・同じクエリの中での使い回しの点で、入れ子のサブクエリより扱いやすくなります。MySQL 8.0以降では追加の設定なしに使えるため、入れ子の深いサブクエリを書きそうになったらCTEへの置き換えを検討してみてください。
もっと学ぶなら【PR】
- 達人に学ぶSQL徹底指南書 第2版:CASE 式やウィンドウ関数など、一歩進んだ書き方がわかる中級者向けの本
- スッキリわかるSQL入門 第4版:練習問題がたくさんある入門書。手を動かして基本を固めたい人向け
※Amazonのアソシエイトとして、当サイトは適格販売により収入を得ています。
参考リンク
アイキャッチ画像: Photo by Jantine Doornbos on Unsplash

