SQLを書いていると、同じサブクエリを複数箇所で使い回したり、ネストが深くなって読めなくなることがあります。そんなときに役立つのがWITH句(共通表式 / CTE: Common Table Expression)です。本記事では、OracleにおけるWITH句の基本から、再帰CTE・パフォーマンス制御・実務パターンまで体系的に解説します。
- WITH句の基本構文と使いどころ
- 複数のCTEを連鎖して定義する方法
- サブクエリとWITH句の使い分け
- 再帰CTE(組織図・連番生成)の実装方法(Oracle 11g R2以降)
- MATERIALIZEとINLINEヒントによるパフォーマンス制御
- 累計計算・ランキングなど実務パターン
WITH句とは
WITH句は、SELECT文の先頭に一時的な名前付きサブクエリ(共通表式)を定義できる構文です。定義したCTEはメインのSELECT文の中でテーブルと同じように参照できます。
WITH句を使う主なメリットは次の3点です。
| メリット | 説明 |
|---|---|
| 可読性の向上 | 深くネストしたサブクエリを分かりやすく分解できる |
| 再利用性 | 同じサブクエリを1回定義して複数箇所から参照できる |
| 再帰クエリ | 自己参照を使った階層データの取得が書ける(11g R2以降) |
OracleではOracle 9i以降でWITH句(非再帰)が使え、Oracle 11g Release 2以降で再帰CTEが利用可能になりました。
基本構文
WITH cte_name AS (
-- ここにサブクエリを書く
SELECT col1, col2
FROM some_table
WHERE 条件
)
SELECT *
FROM cte_name; -- CTEをテーブルのように参照する
WITH句のあとにCTE名と定義を書き、メインのSELECT文でそのCTE名を参照します。CTEはSELECT文が完了するまでのみ有効であり、次のSQL文では参照できません(一時テーブルとは異なります)。
具体例:サブクエリとの比較
部署ごとの平均給与を算出し、全社平均より高い部署だけを抽出するSQLを例に、サブクエリ版とWITH句版を比較します。
SELECT dept_id, avg_salary
FROM (
SELECT dept_id, AVG(salary) AS avg_salary
FROM employees
GROUP BY dept_id
) dept_avg
WHERE avg_salary > (
SELECT AVG(salary)
FROM employees
);
WITH
dept_avg AS (
-- 部署別平均給与
SELECT dept_id, AVG(salary) AS avg_salary
FROM employees
GROUP BY dept_id
),
company_avg AS (
-- 全社平均給与
SELECT AVG(salary) AS avg_salary
FROM employees
)
SELECT d.dept_id, d.avg_salary
FROM dept_avg d, company_avg c
WHERE d.avg_salary > c.avg_salary
ORDER BY d.avg_salary DESC;
WITH句版はCTEごとに意図を名前で表現できるため、SQLを読んでいない人でもロジックを追いやすくなります。
複数のCTEを連鎖して定義する
WITH句では複数のCTEをカンマ区切りで列挙し、後に定義したCTEで前のCTEを参照することができます。
WITH
-- ステップ1: 注文金額を集計する
order_summary AS (
SELECT
customer_id,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM orders
WHERE order_date >= ADD_MONTHS(TRUNC(SYSDATE, 'MM'), -12)
GROUP BY customer_id
),
-- ステップ2: 顧客情報と結合してランク付け
ranked_customers AS (
SELECT
c.customer_name,
o.order_count,
o.total_amount,
RANK() OVER (ORDER BY o.total_amount DESC) AS sales_rank
FROM order_summary o
JOIN customers c ON o.customer_id = c.customer_id
)
-- ステップ3: 上位10社だけを抽出
SELECT customer_name, order_count, total_amount, sales_rank
FROM ranked_customers
WHERE sales_rank <= 10;
各CTEに名前をつけることでSQLがドキュメントのように読めます。RANK関数の詳しい使い方は【Oracle】RANK関数でデータの順位を付ける方法も参照してください。
WITH句を使ったデータ更新・削除
Oracle 12c以降では、UPDATE文やDELETE文の中でもWITH句を使えます(DML with CTE)。ただし、使用頻度が高いのはSELECT文内での使用です。
-- 12c以降: WITH句をUPDATEでも使える
WITH obsolete_products AS (
SELECT product_id
FROM products
WHERE last_sold_date < ADD_MONTHS(SYSDATE, -24)
AND stock_qty = 0
)
UPDATE products
SET status = 'DISCONTINUED'
WHERE product_id IN (SELECT product_id FROM obsolete_products);
再帰CTE(Oracle 11g R2以降)
再帰CTEはCTE自身を参照する特殊な形式で、階層データの取得や連番生成に活用できます。Oracle 11g Release 2以降で使用可能です。
構文は「アンカーメンバー(基底部)」と「再帰メンバー」をUNION ALLで連結する形式です。
WITH cte_name (col1, col2, ...) AS (
-- アンカーメンバー(再帰の起点)
SELECT ...
FROM ...
WHERE ... -- 最上位データを取得
UNION ALL
-- 再帰メンバー(自身を参照して繰り返す)
SELECT ...
FROM some_table t
JOIN cte_name c ON t.parent_id = c.col1
)
SELECT * FROM cte_name;
活用例1:組織図(階層データ)の展開
上司・部下の関係を持つ従業員テーブルから、特定の部門の全階層を一度に取得する例です。
WITH org_tree (emp_id, emp_name, mgr_id, dept_name, level_num, path) AS (
-- アンカー:最上位(manager_idがNULL)の従業員
SELECT
emp_id,
emp_name,
manager_id,
dept_name,
1 AS level_num,
emp_name AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- 再帰:一つ下の階層を結合
SELECT
e.emp_id,
e.emp_name,
e.manager_id,
e.dept_name,
t.level_num + 1,
t.path || ' > ' || e.emp_name
FROM employees e
JOIN org_tree t ON e.manager_id = t.emp_id
)
SELECT
LPAD(' ', (level_num - 1) * 4) || emp_name AS indented_name,
level_num,
dept_name,
path
FROM org_tree
ORDER BY path;
LPADでインデントを付けることで、コンソール上でも組織図のような見た目で出力できます。
Oracleには以前から
START WITH ... CONNECT BY PRIOR という独自の階層問い合わせ構文があります。再帰CTEとの主な違いは次の通りです。
- CONNECT BY:Oracleの独自構文。シンプルな階層取得ではコードが短い。
LEVEL擬似列・SYS_CONNECT_BY_PATH関数が使える - 再帰CTE:標準SQL(SQL:1999)に準拠。他DBへの移植性が高い。複雑な再帰ロジックが書きやすい
新規開発では移植性の高い再帰CTEを選ぶ場面が増えています。
活用例2:連番の生成
1から指定した数までの連番を動的に生成したい場合も再帰CTEが便利です。
WITH nums (n) AS (
SELECT 1 FROM dual -- アンカー:最初の値
UNION ALL
SELECT n + 1 FROM nums -- 再帰:1ずつ増やす
WHERE n < 30 -- 終了条件
)
SELECT n FROM nums;
再帰の深さ上限について
再帰CTEはデフォルトで最大1000回まで再帰します。無限再帰を防ぐため、終了条件(WHERE句)を必ず書いてください。上限を変えたい場合は CYCLE 句を使います。
WITH org_tree (emp_id, emp_name, mgr_id, level_num) AS (
SELECT emp_id, emp_name, manager_id, 1
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.emp_id, e.emp_name, e.manager_id, t.level_num + 1
FROM employees e
JOIN org_tree t ON e.manager_id = t.emp_id
)
CYCLE emp_id SET is_cycle TO '1' DEFAULT '0' -- emp_idが循環したらis_cycle='1'
SELECT emp_id, emp_name, level_num, is_cycle
FROM org_tree
WHERE is_cycle = '0'; -- 循環していない行だけ取得
WITH句のパフォーマンス制御
OracleはWITH句をデフォルトでインライン展開(各参照箇所でサブクエリを展開)します。CTEを複数箇所で参照する場合、オプティマイザが同じクエリを複数回実行することがあります。この動作を制御するために、MATERIALIZEヒントとINLINEヒントを使います。
WITH expensive_cte AS (
/*+ MATERIALIZE */ -- 一度だけ実行して一時領域に保存
SELECT dept_id, SUM(salary) AS total_sal
FROM employees
GROUP BY dept_id
)
-- expensive_cteを2回参照しても実行は1回
SELECT e.dept_id, e.total_sal
FROM expensive_cte e
WHERE e.total_sal > 500000
UNION ALL
SELECT e.dept_id, e.total_sal
FROM expensive_cte e
WHERE e.total_sal <= 500000;
WITH simple_filter AS (
/*+ INLINE */ -- 参照箇所ごとにインライン展開(物化しない)
SELECT * FROM products WHERE status = 'ACTIVE'
)
SELECT * FROM simple_filter WHERE price > 1000;
| ヒント | 動作 | 適したケース |
|---|---|---|
| MATERIALIZE | 一時領域にCTEの結果を保存。複数参照でも1回実行 | コストの高いCTEを複数回参照するとき |
| INLINE | CTEをサブクエリとして展開。オプティマイザに統計を渡せる | CTEが1回しか参照されない・小さい結果セットのとき |
MATERIALIZEを使うとCTEの結果がTEMP表領域(一時表領域)に書き込まれます。大量データを扱う場合はTEMP表領域の容量に注意してください。TEMP表領域の管理方法については【Oracle】TEMP表領域の肥大化の原因とクリーンアップ方法完全ガイドを参照してください。
実務でよく使うパターン
パターン1:累計計算
月次売上の累計をWITH句と分析関数を組み合わせて計算します。
WITH monthly_sales AS (
SELECT
TO_CHAR(order_date, 'YYYY-MM') AS month,
SUM(amount) AS monthly_amount
FROM orders
WHERE order_date >= TRUNC(SYSDATE, 'YYYY') -- 今年のデータ
GROUP BY TO_CHAR(order_date, 'YYYY-MM')
)
SELECT
month,
monthly_amount,
SUM(monthly_amount) OVER (ORDER BY month
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
AS cumulative_amount -- 累計売上
FROM monthly_sales
ORDER BY month;
パターン2:中間テーブルが不要なデータクレンジング
複数ステップの変換処理をCTEで段階的に表現することで、一時テーブルなしにデータクレンジングができます。
WITH
-- ステップ1: 重複レコードを除外
deduped AS (
SELECT DISTINCT
customer_id,
TRIM(customer_name) AS customer_name, -- 前後の空白を除去
email,
phone
FROM raw_customers
),
-- ステップ2: メールアドレスの形式チェック
validated AS (
SELECT
customer_id,
customer_name,
CASE
WHEN REGEXP_LIKE(email, '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
THEN email
ELSE NULL
END AS email, -- 不正な形式はNULLに置き換え
phone
FROM deduped
),
-- ステップ3: 電話番号から数字以外を除去
cleaned AS (
SELECT
customer_id,
customer_name,
email,
REGEXP_REPLACE(phone, '[^0-9]', '') AS phone -- ハイフン等を除去
FROM validated
WHERE customer_name IS NOT NULL
)
-- 最終結果
SELECT * FROM cleaned;
パターン3:集計値の差分・比率を計算する
前月比や前年同月比の計算では、同じ集計を2回参照するためWITH句が効果的です。
WITH monthly AS (
SELECT
TO_CHAR(order_date, 'YYYYMM') AS ym,
SUM(amount) AS sales
FROM orders
GROUP BY TO_CHAR(order_date, 'YYYYMM')
)
SELECT
cur.ym,
cur.sales AS current_sales,
prev.sales AS prev_month_sales,
ROUND((cur.sales - prev.sales) / NULLIF(prev.sales, 0) * 100, 1)
AS mom_growth_pct, -- 前月比(%)
ly.sales AS last_year_sales,
ROUND((cur.sales - ly.sales) / NULLIF(ly.sales, 0) * 100, 1)
AS yoy_growth_pct -- 前年同月比(%)
FROM
monthly cur
LEFT JOIN monthly prev
ON prev.ym = TO_CHAR(ADD_MONTHS(TO_DATE(cur.ym, 'YYYYMM'), -1), 'YYYYMM')
LEFT JOIN monthly ly
ON ly.ym = TO_CHAR(ADD_MONTHS(TO_DATE(cur.ym, 'YYYYMM'), -12), 'YYYYMM')
ORDER BY cur.ym;
パターン4:上位N件の取得をCTEで整理する
WITH dept_ranking AS (
SELECT
dept_id,
emp_name,
salary,
RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rk
FROM employees
)
SELECT dept_id, emp_name, salary, rk
FROM dept_ranking
WHERE rk <= 3 -- 部署ごと上位3名
ORDER BY dept_id, rk;
CASE文を使った条件分岐との組み合わせ方は【Oracle】CASE文とDECODE関数の違いと使い分けも参考になります。
注意点・制限事項
| 制限・注意点 | 説明 |
|---|---|
| スコープ | CTEは定義したSELECT文の中でのみ有効。次のSQL文では使えない |
| 更新不可 | CTEに対してINSERT/UPDATE/DELETEは直接行えない(ベーステーブルに対して行う) |
| DML制約 | UPDATE/DELETE内でのCTE使用はOracle 12c以降 |
| 再帰の上限 | デフォルトで最大1000回。終了条件を必ず書く |
| バージョン | 非再帰CTE: Oracle 9i以降。再帰CTE: Oracle 11g R2以降 |
WITH句 vs サブクエリ vs 一時テーブル
どの方法を使うべきか迷った場合の目安です。
| 方法 | 適したケース | 注意点 |
|---|---|---|
| WITH句(CTE) | 可読性重視・複数箇所で同じ中間結果を参照・再帰クエリ | SQL文の中でのみ有効 |
| インラインビュー(サブクエリ) | 1回だけ使う単純なフィルタ・最適化ヒントをオプティマイザに委ねたい | ネストが深くなると可読性低下 |
| 一時テーブル(GLOBAL TEMPORARY TABLE) | 複数のSQL文を跨いで中間結果を使い回す・PL/SQLとの連携 | テーブル定義が必要・セッション管理が要る |
まとめ
OracleのWITH句(CTE)は、複雑なSQLをステップに分解して可読性を高める強力な道具です。
- 基本形はWITH句でCTEを定義し、SELECT文からテーブルのように参照する
- 複数CTEはカンマ区切りで連鎖定義でき、後のCTEで前のCTEを参照できる
- 再帰CTE(11g R2以降)は階層データの展開・連番生成に使う。終了条件を忘れずに
- MATERIALIZEヒントはコストの高いCTEを複数回参照するときに実行回数を減らせる
- 累計・前月比・ランキングなどの実務パターンでWITH句は特に威力を発揮する
OracleのSQLチューニング全般については【Oracle】SQLの実行計画を確認する方法|EXPLAIN PLANとAUTOTRACEの違い、HAVING句を使った集計条件については【Oracle】HAVING句でグループ化されたデータに条件を適用する方法も合わせて参照してください。

