SQLのCTE(WITH句)入門:基本構文とサブクエリとの違い

black laptop computer turned-on displaying source code on table SQL

SQLクエリが複雑になると、サブクエリが入れ子になって読みにくくなりがちです。WITH句(CTE:共通テーブル式)を使うと、複雑なクエリを名前付きのブロックに分割し、上から順に読み下せる構造に整えられます。本記事では、CTEの基本構文から複数CTEの連結、サブクエリとの使い分け、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_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_totalscustomer_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ごとに単独で実行して確認できる 内側から切り出して確認が必要
性能 オプティマイザの判断に依存(後述) ほぼ同じ

特に有効なのは、同じCTEを同一クエリ内で複数回参照する場面です。次の例ではmonthly_salesをウィンドウ関数と組み合わせて再利用しています。

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;


サブクエリで同じことをしようとすると、LAG()を使うために同じ集計をもう一度サブクエリで書くか、派生テーブルをさらにネストする必要があります。CTEを使えば1か所にまとめられます。

MySQL 8.0のパフォーマンスについて: CTEはデフォルトで、一時テーブルに実体化(マテリアライズ)するかインライン展開するかをオプティマイザが判断します。単純なCTEはサブクエリとほぼ同等の性能です。MySQL 8.0.19以降では/*+ MATERIALIZED(cte_name) */ヒントで明示することもできます。

よくある落とし穴 / 注意点

CTEはVIEWではありません。CTEはそのSQL文のスコープ内だけで有効で、別のクエリからは参照できません。クエリをまたいで使い回したい場合はVIEWを作成してください。

CTE内のORDER BYは保証されません。MySQL 8.0では、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は別途記事で詳しく扱います。

また、複数CTEの連結では参照方向に注意が必要です。後のCTEから前のCTEは参照できますが、前のCTEから後のCTEは参照できません。定義の順序が実行の依存関係を決めます。

まとめ

WITH句(CTE)は、複雑なSQLを処理単位ごとに名前を付けて分割できる構文です。可読性・デバッグのしやすさ・同一クエリ内での再利用性の面でサブクエリより優れています。MySQL 8.0以降では追加の設定なしに使えるため、入れ子の深いサブクエリを書きそうになったらCTEへの置き換えを検討してみてください。

参考リンク

アイキャッチ画像: Photo by Jantine Doornbos on Unsplash

タイトルとURLをコピーしました