WITH句の基本 — CTEを使って一時的なデータセットを作る
CTE(Common Table Expression:共通テーブル式)は、WITH 句を使ってSQLの先頭で「一時的なテーブル」を定義する構文です。
複雑なサブクエリを分割し、コードを上から下へ自然に読めるようにすることで、SQLの可読性を劇的に向上させます。
WITH cte_name AS ( -- 1. ここで「cte_name」という一時テーブルを定義 SELECT id, name FROM users WHERE status = 'active' ) SELECT * -- 2. メインクエリで、定義した「cte_name」を利用する FROM cte_name;
users テーブルから、ステータスが 'active' なユーザーだけを抽出した一時テーブル active_users を CTE で定義してください。
その後、メインクエリで active_users から user_id と name を取得してください。
| user_id | name | status |
|---|---|---|
| 1 | 田中 | active |
| 2 | 佐藤 | inactive |
| 3 | 山田 | active |
| user_id | name |
|---|---|
| 1 | 田中 |
| 3 | 山田 |
-- ① WITH句で「active_users」という名前のCTE(一時テーブル)を定義 WITH active_users AS ( SELECT user_id, name FROM users WHERE status = 'active' -- active な行だけを抽出 ) -- ② メインクエリ: 作成したCTEを本物のテーブルのように扱う SELECT user_id, name FROM active_users ORDER BY user_id; -- 公開する結果順を固定 /* 実行順序 (超基礎): 1. CTE定義: users テーブルから status='active' の行を抽出し、 メモリ上に「active_users」という仮想テーブルを作る 2. メインクエリ: FROM active_users からデータを読み込み、必要な列を出力 */
LEGEND
① CTE内クエリの評価
FROM users WHERE status = 'active'まず、WITH句内のSELECT文が評価されます。usersテーブル全体から条件(status = 'active')に合致しない行が除外され、必要なデータだけが絞り込まれます。| user_id | name | status |
|---|---|---|
| 1 | 田中 | active |
| 2 | 佐藤 | inactive |
| 3 | 山田 | active |
SELECT から書き始めますが、CTEを使うと WITH 変数名 AS ( クエリ ) のように、クエリの最初に部品(仮想テーブル)を定義できます。これにより、「元の大きなテーブルから、必要なデータだけを先に切り出す」という思考プロセスをそのままコードに落とし込めます。複数のCTEを定義 — カンマ区切りで複数の仮想テーブルを繋ぐ
CTEは、カンマ , で区切ることで複数定義することができます。複数のテーブルを事前に前処理(フィルタや集計)してから JOIN する際に非常に有効です。
WITH cte_A AS ( SELECT ... -- 1つ目のCTE ), cte_B AS ( SELECT ... -- 2つ目のCTE (WITH は書かず、カンマで繋ぐ) ) SELECT * FROM cte_A JOIN cte_B ON ... ;
WITH と書いてしまう構文エラーが初心者にとても多いです。users テーブルから rank = 'premium' のユーザーを抽出するCTE premium_users を作成してください。
次に、orders テーブルから amount >= 5000 の注文を抽出するCTE high_orders を作成してください。
最後にメインクエリでこの2つのCTEを user_id で INNER JOIN し、ユーザー名(name)と注文金額(amount)を出力してください。
| user_id | name | rank |
|---|---|---|
| 1 | 田中 | premium |
| 2 | 佐藤 | normal |
| 3 | 山田 | premium |
| order_id | user_id | amount |
|---|---|---|
| 1 | 1 | 8000 |
| 2 | 2 | 12000 |
| 3 | 3 | 3000 |
| 4 | 3 | 9000 |
| name | amount |
|---|---|
| 田中 | 8000 |
| 山田 | 9000 |
-- 1つ目のCTE: プレミアムユーザーを抽出 WITH premium_users AS ( SELECT user_id, name FROM users WHERE rank = 'premium' ), -- ← カンマで繋ぐ!(超重要) high_orders AS ( -- 2つ目のCTE: 5000円以上の高額注文を抽出 SELECT user_id, amount FROM orders WHERE amount >= 5000 ) -- メインクエリ: 作成した2つのCTEを結合する SELECT p.name, h.amount FROM premium_users p JOIN high_orders h ON p.user_id = h.user_id ORDER BY p.user_id, h.amount; /* 実行順序: 1. premium_users を評価 → メモリに一時保持 2. high_orders を評価 → メモリに一時保持 3. メインクエリで JOIN → user_id で結合 */
LEGEND
① 1つ目のCTEの生成
CTE: premium_usersまず users テーブルから特定のユーザーを抽出し、1つ目の一時テーブル(premium_users)を生成します。不要なデータが削ぎ落とされます。| user_id | name | rank |
|---|---|---|
| 1 | 田中 | premium |
| 2 | 佐藤 | normal |
| 3 | 山田 | premium |
WITH cte_A AS (...), WITH cte_B AS (...) と書くと構文エラーになります。WITH はクエリの先頭に1回だけ書き、以降はカンマで繋ぐのがルールです。CTE × 集計 — 集計結果をユーザー情報と結合する
CTEが最も活躍するパターンの1つが「GROUP BY で集計した結果を、マスタデータ(ユーザー情報など)とJOINする」使い方です。
集計処理(GROUP BY)と結合処理(JOIN)を1つの SELECT 内に無理やり書こうとするとコードが複雑化しますが、CTEで集計部分を切り出すことでスッキリと記述できます。
WITH agg AS ( -- ① 集計だけを先に済ませる SELECT key_col, COUNT(*) AS cnt FROM table_name GROUP BY key_col ) SELECT m.name_col, agg.cnt -- ② マスタと結合して読める形にする FROM master_table m JOIN agg ON agg.key_col = m.key_col;
orders テーブルから、ユーザーごとの合計注文金額(total_amount)を計算するCTE user_sales を作成してください。
その後、メインクエリで users テーブルと user_sales を JOIN し、ユーザー名(name)と合計注文金額を出力してください。
出力は合計注文金額の降順(高い順)に並べてください。
| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
| order_id | user_id | amount |
|---|---|---|
| 1 | 1 | 5000 |
| 2 | 1 | 3000 |
| 3 | 2 | 10000 |
| name | total_amount |
|---|---|
| 佐藤 | 10000 |
| 田中 | 8000 |
-- ① CTE: orders テーブルからユーザーごとの合計金額を集計 WITH user_sales AS ( SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id ) -- ② メインクエリ: 集計結果(CTE) と users を結合して名前を取得 SELECT u.name, s.total_amount FROM users u JOIN user_sales s ON u.user_id = s.user_id ORDER BY s.total_amount DESC; /* 実行順序: 1. user_sales 評価 → orders を user_id で集計 2. users と user_sales を JOIN → user_id で結合 3. ORDER BY → 合計金額の降順で並べ替え */
LEGEND
① CTEでのグループ集計
GROUP BY user_id → SUM(amount)orders テーブルをユーザー単位でグループ化(縦の圧縮)し、合計金額を算出します。この集計結果を user_sales という一時テーブルにします。| user_id | total_amount |
|---|---|
| 1 | 8000 |
| 2 | 10000 |
FROM users u JOIN (SELECT user_id, SUM(amount)...) s ON ... というように、FROM句の中でサブクエリを書くことでも実現できます。しかしCTEとして一番上に切り出すことで、「何を集計しているのか」が名前(user_sales)から一目で分かり、コードの構造が劇的に読みやすくなります。自己参照 / 他CTEの参照 — CTEで段階的にロジックを組み立てる
カンマで繋いで定義した複数のCTEは、後から定義したCTEの中で、先に定義したCTEを参照することができます。
WITH cte_1 AS ( SELECT ... -- 処理A(例:不要データの除外) ), cte_2 AS ( SELECT ... FROM cte_1 -- 処理B(処理Aの結果をさらに集計) ) SELECT * FROM cte_2; -- 最終出力
このように処理をチェーン(数珠つなぎ)にすることで、SQLを「上から下へ流れるデータパイプライン」のように読み書きできます。
orders テーブルに対して、以下のステップをCTEを使ってチェーンで実装してください。
- CTE
valid_orders: ステータスが'cancelled'でない注文だけを抽出。 - CTE
user_totals:valid_ordersを元に、ユーザーごとの合計金額(total)を集計。 - メインクエリ:
user_totalsから、合計金額が10000円以上のユーザーのuser_idとtotalを取得。
| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | 1 | 15000 | completed |
| 2 | 1 | 5000 | cancelled |
| 3 | 2 | 8000 | completed |
| 4 | 2 | 3000 | completed |
| user_id | total |
|---|---|
| 1 | 15000 |
| 2 | 11000 |
-- ① 1つ目のCTE: キャンセルされた注文を除外 WITH valid_orders AS ( SELECT user_id, amount FROM orders WHERE status != 'cancelled' ), user_totals AS ( -- ② 2つ目のCTE: 1つ目のCTE(valid_orders)を参照して集計 SELECT user_id, SUM(amount) AS total FROM valid_orders -- 先に定義したCTEを参照 GROUP BY user_id ) -- ③ メインクエリ: 2つ目のCTE(user_totals)を参照してフィルタリング SELECT user_id, total FROM user_totals WHERE total >= 10000 -- 合計1万円以上のユーザーのみ ORDER BY user_id; /* 実行順序: 1. orders から cancelled を除外 → valid_orders を作成 2. user_id でグループ化 → user_totals を作成 3. total のしきい値で抽出 → 条件を満たす行を残す */
LEGEND
① 第1ステップ: 不要データ除外
CTE: valid_ordersまず、キャンセルされた注文を除外します。このように「前処理」を最初のCTEで行うことで、後続の集計ロジックがシンプルになります。| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | 1 | 15000 | completed |
| 2 | 1 | 5000 | cancelled |
| 3 | 2 | 8000 | completed |
| 4 | 2 | 3000 | completed |
SELECT * FROM (SELECT user_id, SUM(amount) FROM (SELECT ... WHERE status != 'cancelled') GROUP BY ...) WHERE total >= 10000; のようになり、マトリョーシカのようなネスト(入れ子)になって非常に読みにくくなります。応用: サブクエリとの連携 — ユーザーごとの最新注文を取得する
実務のWebアプリケーション(例えばユーザーのマイページ表示など)で極めて頻繁に登場するのが「各ユーザーの最新のレコード(注文やログイン履歴)を取得する」という課題です。
これを実現する手法はいくつかありますが、「CTEで各ユーザーの最新日時(MAX)を計算し、それを元テーブルと結合(JOIN)して該当行を特定する」という方法が最も汎用的でわかりやすいアプローチの一つです。
WITH latest AS ( -- ① キーごとの最大値(最新日時)を求める SELECT key_col, MAX(ts_col) AS max_ts FROM table_name GROUP BY key_col ) SELECT t.* FROM table_name t JOIN latest l ON l.key_col = t.key_col AND l.max_ts = t.ts_col; -- ② 最大値と一致する行だけを取り出す
orders テーブルから、各ユーザーの最新(ordered_at が一番大きい)の注文データを取得してください。
まずCTE latest_dates で、ユーザーごとに最大の ordered_at(max_date と命名)を集計します。
次に、元の orders テーブルとCTEを user_id と ordered_at = max_date の 2つの条件で JOIN して、最新注文の order_id、user_id、amount、ordered_at を出力してください。
| order_id | user_id | amount | ordered_at |
|---|---|---|---|
| 1 | 1 | 3000 | 2024-01-10 |
| 2 | 1 | 5000 | 2024-02-15 |
| 3 | 2 | 10000 | 2024-01-20 |
| 4 | 2 | 8000 | 2024-03-01 |
| user_id | order_id | amount | ordered_at |
|---|---|---|---|
| 1 | 2 | 5000 | 2024-02-15 |
| 2 | 4 | 8000 | 2024-03-01 |
-- ① CTE: 各ユーザーの「一番新しい注文日」を特定する WITH latest_dates AS ( SELECT user_id, MAX(ordered_at) AS max_date -- MAX で最新の日付を取得 FROM orders GROUP BY user_id ) -- ② メインクエリ: 元のテーブルとCTEを結合し、最新の行だけを拾い上げる SELECT o.user_id, o.order_id, o.amount, o.ordered_at FROM orders o JOIN latest_dates ld ON o.user_id = ld.user_id -- 複合条件での JOIN: ユーザーIDが同じ かつ 注文日が MAX日付と同じ 行 AND o.ordered_at = ld.max_date ORDER BY o.user_id; /* 実行順序: 1. latest_dates を作成 2. orders(o) と latest_dates(ld) を結合 */
LEGEND
① CTEで最新日付を算出
GROUP BY user_id → MAX(ordered_at)まずユーザーごとに「最も新しい注文日付(MAX)」だけを算出し、latest_dates という一時テーブルを作成します。この時点ではまだ他の列(金額など)は取得できません。| user_id | max_date |
|---|---|
| 1 | 2024-02-15 |
| 2 | 2024-03-01 |
SELECT user_id, order_id, MAX(ordered_at) と書くことはSQLの文法上できません(order_idが集計されていないためエラーになる)。「最新の日付」だけをCTEで計算し、その日付を使って元のテーブルを引き当てる(JOINする)という2段階の処理が必須になります。ROW_NUMBER() というWindow関数を使うさらに高度な書き方(Q8で解説)もあります。