【Oracle】縦持ちデータを横持ちに変換する方法|PIVOT・CASE+GROUP BY・動的PIVOT・UNPIVOT・LISTAGG まで解説

【Oracle】縦持ちデータを横持ちに変換する方法|PIVOT・CASE+GROUP BY・動的PIVOT・UNPIVOT・LISTAGG まで解説 Oracle

データベースでは「月ごとの売上を行として持つ」縦持ち(正規化形式)が基本ですが、レポートや帳票では「月を列に並べた」横持ち(クロス集計形式)が求められます。

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
DB は縦持ち、レポートは横持ち
データベースの正規化設計では縦持ちが基本ですが、Excel 風のレポートやダッシュボードでは横持ちが見やすくなります。Oracle ではこの変換を SQL だけで実現できます。

PIVOT 句で縦→横変換する(11g 以降)

SQL(PIVOT 基本構文)
-- 構文
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 してください。
SQL(NG: 不要な列を含むと行数が増える)
-- 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月'));

列エイリアスの指定

SQL(列エイリアスの指定方法)
-- 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)
);

複数の集計値を同時に横展開

SQL(複数集計の PIVOT)
-- 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 の組み合わせで同じ結果を得られます。

SQL(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;
SQL(DECODE + GROUP BY: Oracle 独自の簡潔な書き方)
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 を動的に生成して実行します。

SQL(動的 PIVOT: 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;
/
動的 PIVOT の注意点
・動的 SQL は SQL インジェクションのリスクがあるため、値のサニタイズが必要
・結果の列数が実行時に変わるため、PL/SQL の静的変数には格納できない
・アプリケーション側で柔軟に処理したい場合は、縦持ちのまま取得してアプリ側で横展開する方が簡単なことが多い

UNPIVOT で横→縦に変換する(逆変換)

UNPIVOT は PIVOT の逆で、横持ちデータを縦持ちに変換します。Excel からインポートした横持ちデータを正規化する場合などに使います。

変換前(横持ち):

商品名 JAN FEB MAR
商品A 1000 1500 1200
SQL(UNPIVOT 基本)
-- 横持ち → 縦持ちに変換
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
SQL(UNPIVOT: NULL を含む列の扱い)
-- デフォルト: 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 列にまとめる方法です。列数が不定の場合に便利です。

SQL(LISTAGG で横展開)
-- 部門ごとに社員名をカンマ区切りで横展開
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): 月別売上クロス集計

SQL(月別クロス集計)
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): ステータス別件数の横展開

SQL(ステータス別件数)
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): アンケート結果の集計(選択肢を横展開)

SQL(アンケート集計)
-- 質問ごとに回答選択肢(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)

SQL(横持ちを縦持ちに正規化)
-- 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)
);

よくある質問

QPIVOT は Oracle のどのバージョンから使えますか?
AOracle 11g(11.1)以降で使用可能です。10g 以前では CASE + GROUP BY 方式を使ってください。
QPIVOT の IN 句に列挙する値を動的にしたいです
APIVOT の IN 句には静的な値しか書けません。値が動的な場合は PL/SQL で SQL を動的に生成して EXECUTE IMMEDIATE で実行します。または、縦持ちのまま取得してアプリケーション側で横展開する方法も検討してください。
QPIVOT で NULL が表示されます
APIVOT の集計対象に該当するデータがないセルは NULL になります。NVL を使って 0 に変換できます。
SELECT product_name, NVL(m01, 0), NVL(m02, 0), NVL(m03, 0) FROM (...
または PIVOT 内の集計関数を工夫します: NVL(SUM(amount), 0) は使えないため、外側の SELECT で NVL を適用します。
QPIVOT と CASE + GROUP BY はどちらが速いですか?
A実行計画はほぼ同等です。Oracle は PIVOT を内部的に CASE + GROUP BY に変換して実行するため、パフォーマンスに大きな差はありません。PIVOT の方がコードが短く可読性が高いので、11g 以降では PIVOT を推奨します。
QUNPIVOT で NULL の行が消えます
AUNPIVOT はデフォルトで NULL を含む行を除外します。NULL の行も含めたい場合は UNPIVOT INCLUDE NULLS と指定してください。
Q横持ちを縦持ちに変換する方法は UNPIVOT 以外にもありますか?
AUNION 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 …)