SQL CTE・WITH句 — クエリ整理とリファクタリングの基礎

基礎CTE (WITH句)SQLリファクタリング可読性PostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

WITH句の基本 — CTEを使って一時的なデータセットを作る

WITHCTE超基礎一時テーブル
前提知識

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;
なぜCTEを使うのか?: 括弧の入れ子(ネスト)が深くなるサブクエリと違い、CTEは「変数に代入して後で使う」ようなプログラミング感覚でSQLを構築できます。
問題

users テーブルから、ステータスが 'active' なユーザーだけを抽出した一時テーブル active_users を CTE で定義してください。

その後、メインクエリで active_users から user_idname を取得してください。

使用テーブル
▸ users
user_idnamestatus
1田中active
2佐藤inactive
3山田active
期待出力
user_idname
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 からデータを読み込み、必要な列を出力
*/
解説(テーブル変化・ポイント)
WITH active_users AS ( SELECT user_id, name FROM users WHERE status = 'active' ) SELECT user_id, name FROM active_users;
LEGEND
評価対象の列・キー
除外・非表示データ
① CTE内クエリの評価
FROM users WHERE status = 'active'まず、WITH句内のSELECT文が評価されます。usersテーブル全体から条件(status = 'active')に合致しない行が除外され、必要なデータだけが絞り込まれます。
1 / 3
user_idnamestatus
1田中active
2佐藤inactive
3山田active
3行 → 2行
学習ポイント
CTE(WITH句)の基本:SQLは通常 SELECT から書き始めますが、CTEを使うと WITH 変数名 AS ( クエリ ) のように、クエリの最初に部品(仮想テーブル)を定義できます。これにより、「元の大きなテーブルから、必要なデータだけを先に切り出す」という思考プロセスをそのままコードに落とし込めます。
スコープ(有効範囲):定義したCTEは、その直後に続く 1 つのメインクエリ(SELECT / UPDATE / DELETE など)の中でだけ利用可能です。クエリが終了すると破棄されます。
実務コラム
この問題のように「WHEREで済む処理」にCTEを使うことは実務ではありません。しかし、複雑な集計やテーブル結合が絡んでくると、CTEで「意味のある単位(例: 有効ユーザー一覧、今月の売上)」に切り出すことで、チーム開発におけるコードの意図が伝わりやすくなります。
QUESTION 2

複数のCTEを定義 — カンマ区切りで複数の仮想テーブルを繋ぐ

複数CTE,JOIN可読性
前提知識

CTEは、カンマ , で区切ることで複数定義することができます。複数のテーブルを事前に前処理(フィルタや集計)してから JOIN する際に非常に有効です。

WITH cte_A AS (
  SELECT ... -- 1つ目のCTE
),
cte_B AS (
  SELECT ... -- 2つ目のCTE (WITH は書かず、カンマで繋ぐ)
)
SELECT * FROM cte_A JOIN cte_B ON ... ;
カンマの忘れに注意: 2つ目以降のCTEを定義する際、カンマを忘れたり、再度 WITH と書いてしまう構文エラーが初心者にとても多いです。
問題

users テーブルから rank = 'premium' のユーザーを抽出するCTE premium_users を作成してください。

次に、orders テーブルから amount >= 5000 の注文を抽出するCTE high_orders を作成してください。

最後にメインクエリでこの2つのCTEを user_id で INNER JOIN し、ユーザー名(name)と注文金額(amount)を出力してください。

使用テーブル
▸ users
user_idnamerank
1田中premium
2佐藤normal
3山田premium
▸ orders
order_iduser_idamount
118000
2212000
333000
439000
期待出力
nameamount
田中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 で結合
  */
解説(テーブル変化・ポイント)
WITH premium_users AS ( SELECT user_id, name FROM users WHERE rank = 'premium' ), high_orders AS ( SELECT user_id, amount FROM orders WHERE amount >= 5000 ) SELECT p.name, h.amount FROM premium_users p JOIN high_orders h ON p.user_id = h.user_id;
LEGEND
評価対象の列・キー
除外・非表示データ
① 1つ目のCTEの生成
CTE: premium_usersまず users テーブルから特定のユーザーを抽出し、1つ目の一時テーブル(premium_users)を生成します。不要なデータが削ぎ落とされます。
1 / 4
user_idnamerank
1田中premium
2佐藤normal
3山田premium
3行 → 2行
学習ポイント
事前に絞ってから結合する(Pre-filtering):メインクエリで大きなテーブル同士を結合してから WHERE で絞り込むよりも、CTEで各テーブルの不要なデータを削ぎ落としてから結合した方が、パフォーマンスが良くなるケースがあります(DBエンジンのオプティマイザにもよります)。
アンチパターン
2つ目のCTEで WITH を書いてしまう:WITH cte_A AS (...), WITH cte_B AS (...) と書くと構文エラーになります。WITH はクエリの先頭に1回だけ書き、以降はカンマで繋ぐのがルールです。
実務コラム
データエンジニアリングや分析基盤(BigQuery や Snowflake)の巨大なSQLでは、CTEが10個以上連なることも珍しくありません。「Aを集計」「Bを整形」「AとBを結合」「最終フォーマットに整える」というように、処理のステップを明確に分割する手段としてCTEは不可欠です。
QUESTION 3

CTE × 集計 — 集計結果をユーザー情報と結合する

CTE + GROUP BYJOIN集計ユーザー分析
前提知識

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)と合計注文金額を出力してください。

出力は合計注文金額の降順(高い順)に並べてください。

使用テーブル
▸ users
user_idname
1田中
2佐藤
3山田
▸ orders
order_iduser_idamount
115000
213000
3210000
期待出力
nametotal_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                   → 合計金額の降順で並べ替え
  */
解説(テーブル変化・ポイント)
WITH user_sales AS ( SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id ) 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;
LEGEND
グループ化キー・集計対象
グループ分類
① CTEでのグループ集計
GROUP BY user_id → SUM(amount)orders テーブルをユーザー単位でグループ化(縦の圧縮)し、合計金額を算出します。この集計結果を user_sales という一時テーブルにします。
1 / 3
user_idtotal_amount
18000
210000
3行 → 2グループ
学習ポイント
集計と結合の分離:「グループごとの合計を出す(縦の圧縮)」処理と、「別のテーブルとくっつける(横の拡張)」処理を同時に行おうとすると混乱します。CTEで先に「縦の圧縮」を終わらせてシンプルな1対1の対応表を作っておくことで、メインクエリでの「横の拡張(JOIN)」が非常に直感的になります。
サブクエリの代替としてのCTE:このSQLは FROM users u JOIN (SELECT user_id, SUM(amount)...) s ON ... というように、FROM句の中でサブクエリを書くことでも実現できます。しかしCTEとして一番上に切り出すことで、「何を集計しているのか」が名前(user_sales)から一目で分かり、コードの構造が劇的に読みやすくなります。
QUESTION 4

自己参照 / 他CTEの参照 — 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を使ってチェーンで実装してください。

  1. CTE valid_orders: ステータスが 'cancelled' でない注文だけを抽出。
  2. CTE user_totals: valid_orders を元に、ユーザーごとの合計金額(total)を集計。
  3. メインクエリ: user_totals から、合計金額が 10000 円以上のユーザーの user_idtotal を取得。
使用テーブル
▸ orders
order_iduser_idamountstatus
1115000completed
215000cancelled
328000completed
423000completed
期待出力
user_idtotal
115000
211000
模範解答コード
-- ① 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 のしきい値で抽出           → 条件を満たす行を残す
  */
解説(テーブル変化・ポイント)
WITH valid_orders AS ( SELECT user_id, amount FROM orders WHERE status != 'cancelled' ), user_totals AS ( SELECT user_id, SUM(amount) AS total FROM valid_orders GROUP BY user_id ) SELECT user_id, total FROM user_totals WHERE total >= 10000;
LEGEND
評価対象の列・キー
除外・非表示データ
① 第1ステップ: 不要データ除外
CTE: valid_ordersまず、キャンセルされた注文を除外します。このように「前処理」を最初のCTEで行うことで、後続の集計ロジックがシンプルになります。
1 / 3
order_iduser_idamountstatus
1115000completed
215000cancelled
328000completed
423000completed
4行 → 3行
学習ポイント
思考プロセスとコードの順序が一致する:「まず不要なデータを消し」「次に集計し」「最後に必要なものだけ残す」という人間の思考の順番通りにSQLを書けるのが、CTEチェーンの最大の強みです。
アンチパターン
サブクエリの入れ子地獄:もしこのSQLをCTEなしで書くと SELECT * FROM (SELECT user_id, SUM(amount) FROM (SELECT ... WHERE status != 'cancelled') GROUP BY ...) WHERE total >= 10000; のようになり、マトリョーシカのようなネスト(入れ子)になって非常に読みにくくなります。
QUESTION 5

応用: サブクエリとの連携 — ユーザーごとの最新注文を取得する

MAX + JOIN最新行取得N+1解消ユーザー分析
前提知識

実務の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_atmax_date と命名)を集計します。
次に、元の orders テーブルとCTEを user_idordered_at = max_date の 2つの条件で JOIN して、最新注文の order_iduser_idamountordered_at を出力してください。

使用テーブル
▸ orders
order_iduser_idamountordered_at
1130002024-01-10
2150002024-02-15
32100002024-01-20
4280002024-03-01
期待出力
user_idorder_idamountordered_at
1250002024-02-15
2480002024-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) を結合
  */
解説(テーブル変化・ポイント)
WITH latest_dates AS ( SELECT user_id, MAX(ordered_at) AS max_date FROM orders GROUP BY user_id ) 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 AND o.ordered_at = ld.max_date ORDER BY o.user_id;
LEGEND
グループ化キー・集計対象
グループ分類
① CTEで最新日付を算出
GROUP BY user_id → MAX(ordered_at)まずユーザーごとに「最も新しい注文日付(MAX)」だけを算出し、latest_dates という一時テーブルを作成します。この時点ではまだ他の列(金額など)は取得できません。
1 / 3
user_idmax_date
12024-02-15
22024-03-01
4行 → 2グループ (CTE完成)
学習ポイント
なぜJOINが必要なのか?:CTEの中で SELECT user_id, order_id, MAX(ordered_at) と書くことはSQLの文法上できません(order_idが集計されていないためエラーになる)。「最新の日付」だけをCTEで計算し、その日付を使って元のテーブルを引き当てる(JOINする)という2段階の処理が必須になります。
実務コラム
Webアプリケーションのバックエンド(ORマッパー)で「100人のユーザー一覧を表示する際、それぞれの最新のログイン履歴を紐づける」という実装をすると、いわゆる「N+1問題」(DBへ101回クエリを投げてしまう問題)が発生しがちです。この問題のSQLを使えば、DBへの1回の問い合わせで全員の最新履歴を一括取得できるため、パフォーマンス改善の切り札として頻繁に使われます。※最近のPostgreSQL等では ROW_NUMBER() というWindow関数を使うさらに高度な書き方(Q8で解説)もあります。