データベースでは「月ごとの売上を行として持つ」縦持ち(正規化形式)が基本ですが、レポートや帳票では「月を列に並べた」横持ち(クロス集計形式)が求められます。
Oracle では PIVOT 句(11g 以降)を使えば 1 つの SQL で縦横変換ができますが、「展開する列の値が事前にわからない」「11g 以前の環境」といったケースでは別のアプローチが必要です。
本記事では、PIVOT 句の基本から、CASE + GROUP BY(旧方式)、動的 PIVOT(列数が不定の場合)、UNPIVOT(横→縦の逆変換)、LISTAGG(値を文字列結合して横展開)まで体系的に解説します。
・縦持ちと横持ちの概念
・PIVOT 句の基本構文と動作(11g 以降)
・CASE + GROUP BY で縦横変換する方法(全バージョン対応)
・列数が不定の場合の動的 PIVOT(PL/SQL)
・UNPIVOT で横持ちを縦持ちに戻す方法
・LISTAGG で値を文字列結合して横展開する方法
・月別クロス集計・ステータス別件数の実務パターン
縦持ちと横持ちの概念
縦持ちデータ(正規化形式)
| 商品名 | 月 | 売上 |
|---|---|---|
| 商品A | 1月 | 1000 |
| 商品A | 2月 | 1500 |
| 商品A | 3月 | 1200 |
| 商品B | 1月 | 800 |
| 商品B | 2月 | 900 |
| 商品B | 3月 | 1100 |
横持ちデータ(クロス集計形式)
| 商品名 | 1月 | 2月 | 3月 |
|---|---|---|---|
| 商品A | 1000 | 1500 | 1200 |
| 商品B | 800 | 900 | 1100 |
データベースの正規化設計では縦持ちが基本ですが、Excel 風のレポートやダッシュボードでは横持ちが見やすくなります。Oracle ではこの変換を SQL だけで実現できます。
PIVOT 句で縦→横変換する(11g 以降)
-- 構文
SELECT *
FROM (
-- ソースクエリ(変換前の縦持ちデータ)
SELECT product_name, month, sales_amount
FROM sales
)
PIVOT (
SUM(sales_amount) -- 集計関数(SUM / COUNT / AVG / MAX / MIN)
FOR month -- 横に展開する列
IN ('1月' AS m01, -- 展開する値とエイリアス
'2月' AS m02,
'3月' AS m03)
);
実行結果:
| PRODUCT_NAME | M01 | M02 | M03 |
|---|---|---|---|
| 商品A | 1000 | 1500 | 1200 |
| 商品B | 800 | 900 | 1100 |
PIVOT のソースクエリに含まれる列のうち、PIVOT の集計関数にも FOR にも使われない列が暗黙の GROUP BY として扱われます。不要な列がソースに含まれると、意図しないグループ化が起きて行数が増えます。ソースクエリでは必要な列だけ SELECT してください。
-- NG: row_id などの一意列がソースにあるとグループ化されない
SELECT * FROM (
SELECT row_id, product_name, month, sales_amount FROM sales -- row_id が余分
)
PIVOT (SUM(sales_amount) FOR month IN ('1月', '2月', '3月'));
-- → row_id ごとに 1 行になり、横展開にならない
-- OK: 必要な列だけ指定
SELECT * FROM (
SELECT product_name, month, sales_amount FROM sales
)
PIVOT (SUM(sales_amount) FOR month IN ('1月', '2月', '3月'));
列エイリアスの指定
-- AS で列名を指定
SELECT product_name, jan, feb, mar
FROM (
SELECT product_name, month_num, sales_amount FROM sales
)
PIVOT (
SUM(sales_amount)
FOR month_num
IN (1 AS jan, 2 AS feb, 3 AS mar,
4 AS apr, 5 AS may, 6 AS jun,
7 AS jul, 8 AS aug, 9 AS sep,
10 AS oct, 11 AS nov, 12 AS dec)
);
複数の集計値を同時に横展開
-- SUM と COUNT を同時に横展開
SELECT *
FROM (
SELECT product_name, month, sales_amount FROM sales
)
PIVOT (
SUM(sales_amount) AS total,
COUNT(*) AS cnt
FOR month
IN ('1月' AS m01, '2月' AS m02, '3月' AS m03)
);
-- 結果列: M01_TOTAL, M01_CNT, M02_TOTAL, M02_CNT, M03_TOTAL, M03_CNT
CASE + GROUP BY で縦→横変換する(全バージョン対応)
PIVOT 句が使えない環境(10g 以前)や、PIVOT の構文が複雑になりすぎる場合は、CASE 式と GROUP BY の組み合わせで同じ結果を得られます。
SELECT
product_name,
SUM(CASE WHEN month = '1月' THEN sales_amount END) AS m01,
SUM(CASE WHEN month = '2月' THEN sales_amount END) AS m02,
SUM(CASE WHEN month = '3月' THEN sales_amount END) AS m03
FROM sales
GROUP BY product_name
ORDER BY product_name;
SELECT
product_name,
SUM(DECODE(month, '1月', sales_amount)) AS m01,
SUM(DECODE(month, '2月', sales_amount)) AS m02,
SUM(DECODE(month, '3月', sales_amount)) AS m03
FROM sales
GROUP BY product_name
ORDER BY product_name;
| 方式 | メリット | デメリット |
|---|---|---|
| PIVOT | 構文が簡潔。複数集計も容易 | IN 句に値を列挙する必要がある(静的) |
| CASE + GROUP BY | 全バージョン対応。条件が柔軟 | 列数が多いと冗長になる |
| DECODE + GROUP BY | CASE より短く書ける(Oracle 独自) | 他 RDBMS に移植不可 |
動的 PIVOT(列数が不定の場合)
PIVOT の IN 句に列挙する値が事前にわからない場合(例: 月が動的、カテゴリが可変)、PL/SQL で SQL を動的に生成して実行します。
DECLARE
v_columns VARCHAR2(4000);
v_sql VARCHAR2(4000);
v_cur SYS_REFCURSOR;
BEGIN
-- (1) IN 句に列挙する値を動的に取得
SELECT LISTAGG('''' || month || ''' AS ' || month, ', ')
WITHIN GROUP (ORDER BY month)
INTO v_columns
FROM (SELECT DISTINCT month FROM sales);
-- 結果例: '1月' AS 1月, '2月' AS 2月, '3月' AS 3月
-- (2) PIVOT SQL を動的に組み立て
v_sql := 'SELECT * FROM ('
|| ' SELECT product_name, month, sales_amount FROM sales'
|| ') PIVOT ('
|| ' SUM(sales_amount) FOR month IN (' || v_columns || ')'
|| ')';
-- (3) 実行
DBMS_OUTPUT.PUT_LINE(v_sql); -- デバッグ用
OPEN v_cur FOR v_sql;
-- v_cur を使って結果を処理
END;
/
・動的 SQL は SQL インジェクションのリスクがあるため、値のサニタイズが必要
・結果の列数が実行時に変わるため、PL/SQL の静的変数には格納できない
・アプリケーション側で柔軟に処理したい場合は、縦持ちのまま取得してアプリ側で横展開する方が簡単なことが多い
UNPIVOT で横→縦に変換する(逆変換)
UNPIVOT は PIVOT の逆で、横持ちデータを縦持ちに変換します。Excel からインポートした横持ちデータを正規化する場合などに使います。
変換前(横持ち):
| 商品名 | JAN | FEB | MAR |
|---|---|---|---|
| 商品A | 1000 | 1500 | 1200 |
-- 横持ち → 縦持ちに変換
SELECT product_name, month, sales_amount
FROM sales_wide
UNPIVOT (
sales_amount -- 値を格納する列
FOR month -- カテゴリ名を格納する列
IN (jan AS '1月', -- 元の列名と変換後の値
feb AS '2月',
mar AS '3月')
);
変換後(縦持ち):
| PRODUCT_NAME | MONTH | SALES_AMOUNT |
|---|---|---|
| 商品A | 1月 | 1000 |
| 商品A | 2月 | 1500 |
| 商品A | 3月 | 1200 |
-- デフォルト: NULL の行は除外される SELECT * FROM sales_wide UNPIVOT (sales_amount FOR month IN (jan, feb, mar)); -- jan=1000, feb=NULL, mar=1200 → feb の行は出力されない -- INCLUDE NULLS: NULL の行も含める SELECT * FROM sales_wide UNPIVOT INCLUDE NULLS (sales_amount FOR month IN (jan, feb, mar)); -- feb の行も NULL のまま出力される
LISTAGG で値を文字列結合して横展開する
値を列として展開するのではなく、カンマ区切りの文字列として 1 列にまとめる方法です。列数が不定の場合に便利です。
-- 部門ごとに社員名をカンマ区切りで横展開
SELECT department_id,
LISTAGG(last_name, ', ') WITHIN GROUP (ORDER BY last_name) AS members
FROM employees
GROUP BY department_id;
-- 結果例:
-- dept_id=10 members='King, Smith, Ward'
-- dept_id=20 members='Adams, Ford, Jones'
-- 重複除去(Oracle 19c 以降)
SELECT department_id,
LISTAGG(DISTINCT job_id, ', ') WITHIN GROUP (ORDER BY job_id) AS jobs
FROM employees
GROUP BY department_id;
| 方式 | 結果の形 | 適するケース |
|---|---|---|
| PIVOT / CASE+GROUP BY | 値ごとに別の列 | 列数が固定の集計レポート |
| LISTAGG | 1 列にカンマ区切り文字列 | 列数が不定、一覧表示 |
実務パターン集
パターン(1): 月別売上クロス集計
SELECT *
FROM (
SELECT product_name,
TO_CHAR(order_date, 'YYYY-MM') AS ym,
amount
FROM orders
WHERE order_date >= DATE '2025-01-01'
AND order_date < DATE '2026-01-01'
)
PIVOT (
SUM(amount)
FOR ym IN (
'2025-01' AS jan, '2025-02' AS feb, '2025-03' AS mar,
'2025-04' AS apr, '2025-05' AS may, '2025-06' AS jun,
'2025-07' AS jul, '2025-08' AS aug, '2025-09' AS sep,
'2025-10' AS oct, '2025-11' AS nov, '2025-12' AS dec
)
)
ORDER BY product_name;
パターン(2): ステータス別件数の横展開
SELECT *
FROM (
SELECT department_id, status FROM employees
)
PIVOT (
COUNT(*)
FOR status IN ('ACTIVE' AS active, 'INACTIVE' AS inactive, 'PENDING' AS pending)
);
-- 結果:
-- DEPARTMENT_ID ACTIVE INACTIVE PENDING
-- 10 25 3 2
-- 20 30 5 1
パターン(3): アンケート結果の集計(選択肢を横展開)
-- 質問ごとに回答選択肢(A/B/C/D)の件数を横展開
SELECT *
FROM (
SELECT question_id, answer FROM survey_responses
)
PIVOT (
COUNT(*)
FOR answer IN ('A' AS choice_a, 'B' AS choice_b,
'C' AS choice_c, 'D' AS choice_d)
)
ORDER BY question_id;
パターン(4): Excel インポートデータの正規化(UNPIVOT)
-- Excel から取り込んだ横持ちテーブル
-- import_sales (product, jan_sales, feb_sales, mar_sales)
INSERT INTO sales_normalized (product, month, amount)
SELECT product, month, amount
FROM import_sales
UNPIVOT (
amount FOR month
IN (jan_sales AS 1, feb_sales AS 2, mar_sales AS 3)
);
よくある質問
SELECT product_name, NVL(m01, 0), NVL(m02, 0), NVL(m03, 0) FROM (...または PIVOT 内の集計関数を工夫します:
NVL(SUM(amount), 0) は使えないため、外側の SELECT で NVL を適用します。UNPIVOT はデフォルトで NULL を含む行を除外します。NULL の行も含めたい場合は UNPIVOT INCLUDE NULLS と指定してください。UNION ALL で各列を個別に SELECT して縦に結合する方法があります。SELECT product, '1月' AS month, jan AS amount FROM table
UNION ALL
SELECT product, '2月', feb FROM table
UNION ALL ...列数が少なければ UNION ALL でも十分ですが、列数が多い場合は UNPIVOT がシンプルです。
まとめ
縦横変換の方法をまとめます。
| やりたいこと | 推奨方法 |
|---|---|
| 縦→横(列の値が固定・11g 以降) | PIVOT 句 |
| 縦→横(10g 以前 / 複雑な条件) | CASE + GROUP BY(または DECODE + GROUP BY) |
| 縦→横(列の値が不定) | 動的 PIVOT(PL/SQL + EXECUTE IMMEDIATE) |
| 縦→横(値を文字列結合) | LISTAGG(…) WITHIN GROUP (ORDER BY …) |
| 横→縦(正規化) | UNPIVOT 句 |
| 横→縦(NULL も保持) | UNPIVOT INCLUDE NULLS |
| 横→縦(10g 以前) | UNION ALL で各列を個別 SELECT |
| 複数の集計値を同時に横展開 | PIVOT (SUM(a) AS total, COUNT(*) AS cnt FOR …) |
