SQLで縦持ちを横持ちに変えるには「CASE式+集約関数+GROUP BY」、横持ちを縦持ちに戻すには「UNION ALL」か「LATERAL」を使うのが、どのデータベースでも通じる基本形です。 PIVOT 句を持つデータベース(SQL Server、Oracle、BigQuery、DuckDB など)ではもっと短く書けますが、MySQL と PostgreSQL には PIVOT 句がないため、この基本形や各DBの独自機能を使い分けます。

この記事では、同じ架空の売上データ(商品×月)を MySQL 8.0.46 と PostgreSQL 16.15 に入れ、ピボット(縦→横)とアンピボット(横→縦)の書き方を並べて実行しました。PostgreSQL の FILTER 句・crosstab、MySQL の JSON_TABLE、列が増える場合の動的ピボット、pandas と DuckDB の PIVOT、NULL と0の扱い、合計行・合計列の付け方まで、実行結果つきでまとめています。

MySQL 8.0 は2026年4月にサポートが終了しています。この記事の MySQL のSQLは MySQL 8.4 LTS / 9.7 LTS でも同じように使えます(使う機能はすべて 8.0 で追加済みのもの)。検証は手元の MySQL 8.0.46 で行いました。

縦持ち⇔横持ち変換の早見表

やりたいこと MySQL 8.0 / 8.4 / 9.7 PostgreSQL 16 備考
縦→横(列が決まっている) SUM(CASE WHEN 列='値' THEN 数値 END) 同左、または SUM(数値) FILTER (WHERE 列='値') どのDBでも通じる基本形
縦→横(専用機能) なし crosstab()(tablefunc 拡張) 列定義リストが必要
縦→横(列が動的に増える) GROUP_CONCAT でSQLを組み立て PREPARE string_agg + format で組み立て \gexec / 関数 列名は実行時に決まる
横→縦(基本) UNION ALL UNION ALL 列の数だけ SELECT を書く
横→縦(1回の読み取り) CROSS JOIN LATERAL (SELECT ... UNION ALL ...)(8.0.14以降) CROSS JOIN LATERAL (VALUES ...) テーブルを1回だけ読む
横→縦(列名を書かない) JSON_TABLE(列名は書く) jsonb_each_text(to_jsonb(行) - 'キー列') PG は列が増えてもSQLの変更不要
合計行 GROUP BY 列 WITH ROLLUP + GROUPING() GROUP BY ROLLUP (列) + GROUPING()
合計列 SUM(数値) AS total を追加 同左
NULL を 0 にする ELSE 0 または COALESCE(...,0) 同左 AVG では意味が変わるので注意
アプリ側で変換 pandas pivot_table / melt 同左 列の動的な増減に強い

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「SQLで縦持ち⇔横持ち変換|MySQL・PostgreSQL別コピペ例と対応表」

縦持ちと横持ちの違い

縦持ち(ロング形式)は「1行=1つの値」で、項目名を列ではなく行に持つ形です。横持ち(ワイド形式)は「1行=1つの対象」で、項目ごとに列を並べる形です。

flowchart LR
    subgraph 縦持ち["縦持ち(ロング)"]
        A["item | month | amount<br/>ノート | 2026-07 | 120<br/>ノート | 2026-08 | 150<br/>ペン | 2026-07 | 100<br/>…"]
    end
    subgraph 横持ち["横持ち(ワイド)"]
        B["item | m07 | m08 | m09<br/>ノート | 120 | 150 | 90<br/>ペン | 100 | NULL | 110<br/>…"]
    end
    A -- "ピボット(PIVOT)" --> B
    B -- "アンピボット(UNPIVOT)" --> A
用語 意味 別名
縦持ち 1行に1つの値。月が増えても行が増えるだけ ロング形式、long format、正規化された形
横持ち 1行に1つの対象。月が増えると列が増える ワイド形式、wide format、クロス集計表
ピボット 縦持ち → 横持ち PIVOT、クロス集計、crosstab
アンピボット 横持ち → 縦持ち UNPIVOT、melt
観点 縦持ちが向いている 横持ちが向いている
保存(テーブル設計) ○ 月が増えてもテーブル定義を変えない 列の追加が必要
集計・絞り込み ○ WHERE month = ... で簡単 列ごとに条件を書く
人が読む表・帳票 行が多く比較しにくい ○ ひと目で比較できる
グラフ・BIツール ツールにより両方 ツールにより両方

「保存は縦持ち、表示の直前に横持ちへ」が扱いやすい組み合わせです。

この記事で使うデータ

-- MySQL 8.0.46 / PostgreSQL 16.15 共通
CREATE TABLE sales (
  item  VARCHAR(20) NOT NULL,
  month VARCHAR(7)  NOT NULL,
  amount INT NOT NULL
);
INSERT INTO sales (item, month, amount) VALUES
 ('ノート',   '2026-07', 120),
 ('ノート',   '2026-08', 150),
 ('ノート',   '2026-09',  90),
 ('ペン',     '2026-07',  80),
 ('ペン',     '2026-07',  20),
 ('ペン',     '2026-09', 110),
 ('ファイル', '2026-08',  60),
 ('ファイル', '2026-09',   0);

ペンの7月は2行(80と20)、ペンの8月とファイルの7月はデータなし、ファイルの9月は売上0という、つまずきやすい要素をわざと入れています。

縦持ち→横持ち(ピボット)のSQL

CASE式+SUM(MySQL・PostgreSQL 共通)

SELECT item,
       SUM(CASE WHEN month = '2026-07' THEN amount END) AS m07,
       SUM(CASE WHEN month = '2026-08' THEN amount END) AS m08,
       SUM(CASE WHEN month = '2026-09' THEN amount END) AS m09
  FROM sales
 GROUP BY item
 ORDER BY item;

MySQL 8.0.46 の実行結果:

+--------------+------+------+------+
| item         | m07  | m08  | m09  |
+--------------+------+------+------+
| ノート       |  120 |  150 |   90 |
| ファイル     | NULL |   60 |    0 |
| ペン         |  100 | NULL |  110 |
+--------------+------+------+------+

PostgreSQL 16.15 の実行結果:

   item   | m07 | m08 | m09
----------+-----+-----+-----
 ノート   | 120 | 150 |  90
 ファイル |     |  60 |   0
 ペン     | 100 |     | 110
(3 rows)

しくみは「CASE で該当月以外を NULL にしてから SUM で1行にまとめる」です。ペンの7月は80+20=100に合算され、データのない月は NULL、売上0の月は 0 になりました。CASE 式そのものの書き方は別記事「SQL CASE式の使い方」、GROUP BY の詳細は「SQL GROUP BY HAVING 使い方」で扱っています。

集約関数 1セルに入る値 使いどころ
SUM(CASE ... THEN amount END) 合計 売上・数量のクロス集計
COUNT(CASE ... THEN 1 END) 件数 月別の注文件数
MAX(CASE ... THEN amount END) 1つの値 1セルに1行しかない縦持ちを横に並べるだけのとき
AVG(CASE ... THEN amount END) 平均 平均単価など(ELSE 0 は付けない)

PostgreSQL の FILTER 句

PostgreSQL では、集約関数に FILTER (WHERE ...) を付けて同じことを短く書けます(SQL標準の構文で、MySQL 8.0 では使えません)。

SELECT item,
       SUM(amount) FILTER (WHERE month = '2026-07') AS m07,
       SUM(amount) FILTER (WHERE month = '2026-08') AS m08,
       SUM(amount) FILTER (WHERE month = '2026-09') AS m09
  FROM sales GROUP BY item ORDER BY item;
   item   | m07 | m08 | m09
----------+-----+-----+-----
 ノート   | 120 | 150 |  90
 ファイル |     |  60 |   0
 ペン     | 100 |     | 110
(3 rows)

結果は CASE 版と同じです。条件と集計対象が分かれて読みやすいのが利点です。

PostgreSQL の crosstab(tablefunc 拡張)

crosstab は「行名・列名・値」の3列を返すSQLを受け取り、横持ちに並べ替える関数です。

CREATE EXTENSION IF NOT EXISTS tablefunc;   -- 拡張の作成権限が必要

SELECT * FROM crosstab(
  $$SELECT item, month, SUM(amount) FROM sales GROUP BY item, month ORDER BY 1, 2$$,
  $$VALUES ('2026-07'), ('2026-08'), ('2026-09')$$
) AS ct(item VARCHAR, m07 BIGINT, m08 BIGINT, m09 BIGINT);
   item   | m07 | m08 | m09
----------+-----+-----+-----
 ノート   | 120 | 150 |  90
 ファイル |     |  60 |   0
 ペン     | 100 |     | 110
(3 rows)

2つ目の引数(列の候補リスト)を省くと、値が左詰めになります。 1引数版で実行した結果です。

   item   | m07 | m08 | m09
----------+-----+-----+-----
 ノート   | 120 | 150 |  90
 ファイル |  60 |   0 |
 ペン     | 100 | 110 |
(3 rows)

ファイルの8月(60)が m07 の列に入ってしまいました。1引数版は「行ごとに来た順に左から埋める」動きのため、欠けている月があるデータでは2引数版を使うのが確実です。

crosstab で出やすいエラーです。

エラー 原因 直し方
ERROR: a column definition list is required for functions returning "record" AS ct(...) の列定義を書いていない 戻り値の列名と型をすべて書く
ERROR: invalid input syntax for type integer: "ノート" 列定義の型が実データと違う(行名列を INT にした) 1列目は行名の型、2列目以降は値の型にそろえる
ERROR: function crosstab(unknown, unknown) does not exist tablefunc 拡張を作っていない CREATE EXTENSION tablefunc;

3つとも PostgreSQL 16.15 で実際に出たメッセージです(3つ目は tablefunc 拡張を削除した状態で実行)。

ピボット方法の比較

方法 対応DB 列の指定 長所 気をつけたい点
CASE+集約 ほぼすべて SQLに書く 移植しやすい 列が多いとSQLが長い
FILTER PostgreSQL、SQLite 3.30以降、DuckDB など SQLに書く 読みやすい MySQL では使えない
crosstab PostgreSQL(拡張) 列定義リスト 大量の列でも元SQLが短い 拡張の作成と型指定が必要
PIVOT 句 SQL Server、Oracle、BigQuery、DuckDB など IN リスト 最も短い MySQL・PostgreSQL にはない

横持ち→縦持ち(アンピボット)のSQL

ここからは横持ちのテーブルを縦持ちに戻します。

-- MySQL 8.0.46 / PostgreSQL 16.15 共通
CREATE TABLE sales_wide (item VARCHAR(20) PRIMARY KEY, m07 INT, m08 INT, m09 INT);
INSERT INTO sales_wide VALUES
 ('ノート', 120, 150, 90), ('ファイル', NULL, 60, 0), ('ペン', 100, NULL, 110);

UNION ALL(MySQL・PostgreSQL 共通)

SELECT item, '2026-07' AS month, m07 AS amount FROM sales_wide
UNION ALL
SELECT item, '2026-08', m08 FROM sales_wide
UNION ALL
SELECT item, '2026-09', m09 FROM sales_wide
ORDER BY item, month;

MySQL 8.0.46 の実行結果:

+--------------+---------+--------+
| item         | month   | amount |
+--------------+---------+--------+
| ノート       | 2026-07 |    120 |
| ノート       | 2026-08 |    150 |
| ノート       | 2026-09 |     90 |
| ファイル     | 2026-07 |   NULL |
| ファイル     | 2026-08 |     60 |
| ファイル     | 2026-09 |      0 |
| ペン         | 2026-07 |    100 |
| ペン         | 2026-08 |   NULL |
| ペン         | 2026-09 |    110 |
+--------------+---------+--------+

PostgreSQL でも同じ9行が返りました。UNION ALL は NULL のセルもそのまま行になります。不要なら外側で WHERE amount IS NOT NULL を付けます。UNION(ALL なし)にすると重複行が消えるため、アンピボットでは UNION ALL を使います。

PostgreSQL:CROSS JOIN LATERAL (VALUES …)

UNION ALL はテーブルを列の数だけ読みますが、LATERAL ならテーブルを1回読むだけで済みます。

SELECT w.item, v.month, v.amount
  FROM sales_wide w
  CROSS JOIN LATERAL (VALUES ('2026-07', w.m07), ('2026-08', w.m08), ('2026-09', w.m09))
       AS v(month, amount)
 WHERE v.amount IS NOT NULL
 ORDER BY w.item, v.month;
   item   |  month  | amount
----------+---------+--------
 ノート   | 2026-07 |    120
 ノート   | 2026-08 |    150
 ノート   | 2026-09 |     90
 ファイル | 2026-08 |     60
 ファイル | 2026-09 |      0
 ペン     | 2026-07 |    100
 ペン     | 2026-09 |    110
(7 rows)

配列を使う書き方もあります(結果は NULL を含む9行)。

SELECT w.item, u.month, u.amount
  FROM sales_wide w
  CROSS JOIN LATERAL unnest(ARRAY['2026-07','2026-08','2026-09'],
                            ARRAY[w.m07, w.m08, w.m09]) AS u(month, amount)
 ORDER BY w.item, u.month;

MySQL:CROSS JOIN LATERAL(8.0.14以降)

MySQL 8.0.14 以降も LATERAL 派生テーブルが使えます。中身は SELECT ... UNION ALL で書きます。

SELECT w.item, v.month, v.amount
  FROM sales_wide w
  CROSS JOIN LATERAL (
    SELECT '2026-07' AS month, w.m07 AS amount
    UNION ALL SELECT '2026-08', w.m08
    UNION ALL SELECT '2026-09', w.m09
  ) AS v
 WHERE v.amount IS NOT NULL
 ORDER BY w.item, v.month;
+--------------+---------+--------+
| item         | month   | amount |
+--------------+---------+--------+
| ノート       | 2026-07 |    120 |
| ノート       | 2026-08 |    150 |
| ノート       | 2026-09 |     90 |
| ファイル     | 2026-08 |     60 |
| ファイル     | 2026-09 |      0 |
| ペン         | 2026-07 |    100 |
| ペン         | 2026-09 |    110 |
+--------------+---------+--------+

JSON関数でアンピボットする

PostgreSQL は行全体を jsonb に変えて展開すると、列名をSQLに書かずに縦持ちにできます。 列が増えてもSQLを直す必要がありません。

SELECT w.item, j.key AS col, j.value::int AS amount
  FROM sales_wide w
  CROSS JOIN LATERAL jsonb_each_text(to_jsonb(w) - 'item') AS j
 ORDER BY w.item, j.key;
   item   | col | amount
----------+-----+--------
 ノート   | m07 |    120
 ノート   | m08 |    150
 ノート   | m09 |     90
 ファイル | m07 |
 ファイル | m08 |     60
 ファイル | m09 |      0
 ペン     | m07 |    100
 ペン     | m08 |
 ペン     | m09 |    110
(9 rows)

MySQL では JSON_TABLE(8.0.4以降)を使います。こちらは列名を書く必要があります。

SELECT w.item, j.month, j.amount
  FROM sales_wide w,
       JSON_TABLE(
         JSON_ARRAY(JSON_OBJECT('month','2026-07','amount',w.m07),
                    JSON_OBJECT('month','2026-08','amount',w.m08),
                    JSON_OBJECT('month','2026-09','amount',w.m09)),
         '$[*]' COLUMNS (month VARCHAR(7) PATH '$.month', amount INT PATH '$.amount')
       ) AS j
 ORDER BY w.item, j.month;

実行結果は UNION ALL 版と同じ9行(NULL を含む)でした。jsonb の詳しい操作は別記事「PostgreSQL jsonb操作一覧」で扱っています。

アンピボット方法の比較

方法 MySQL PostgreSQL テーブルの読み取り 列が増えたとき
UNION ALL ○ ○ 列の数だけ SELECT を追加
LATERAL (VALUES) ×(SELECT UNION ALL で代用) ○ 1回 VALUES に追加
LATERAL (SELECT UNION ALL) ○ 8.0.14以降 ○ 1回 追加
unnest(配列) × ○ 1回 配列に追加
JSON_TABLE ○ 8.0.4以降 ○(PG 17以降。16は未対応) 1回 追加
jsonb_each_text(to_jsonb(行)) × ○ 1回 変更不要

DB別のPIVOT/UNPIVOT対応表

データベース PIVOT 句 UNPIVOT 句 代わりの方法 本記事での確認
MySQL 8.0 / 8.4 / 9.7 なし なし CASE+集約、UNION ALL、LATERAL、JSON_TABLE 8.0.46 で実行
PostgreSQL 16 なし なし CASE、FILTER、crosstab、LATERAL VALUES、jsonb 16.15 で実行
DuckDB あり(独自の簡易構文とSQL標準形) あり - 1.5.6 で実行
SQL Server あり あり - 構文のみ・未実行
Oracle Database あり あり(INCLUDE NULLS 可) - 構文のみ・未実行
BigQuery あり あり(INCLUDE NULLS 可) 動的は EXECUTE IMMEDIATE 構文のみ・未実行

DuckDB の PIVOT / UNPIVOT(実行結果つき)

DuckDB は pip で入れられる組み込み型のデータベースで、PIVOT 句を手元で試せます(Python 3.14.7、duckdb 1.5.6 で実行)。

import duckdb
con = duckdb.connect()
con.execute("""
CREATE TABLE sales AS SELECT * FROM (VALUES
 ('ノート','2026-07',120),('ノート','2026-08',150),('ノート','2026-09',90),
 ('ペン','2026-07',80),('ペン','2026-07',20),('ペン','2026-09',110),
 ('ファイル','2026-08',60),('ファイル','2026-09',0)) t(item, month, amount)
""")
# 列の値を自動で列名にする簡易構文
print(con.sql("PIVOT sales ON month USING SUM(amount) GROUP BY item ORDER BY item"))
┌──────────┬─────────┬─────────┬─────────┐
│   item   │ 2026-07 │ 2026-08 │ 2026-09 │
│ varchar  │ int128  │ int128  │ int128  │
├──────────┼─────────┼─────────┼─────────┤
│ ノート   │     120 │     150 │      90 │
│ ファイル │    NULL │      60 │       0 │
│ ペン     │     100 │    NULL │     110 │
└──────────┴─────────┴─────────┴─────────┘
con.execute("CREATE TABLE sales_wide AS SELECT * FROM (VALUES ('ノート',120,150,90),"
            "('ファイル',NULL,60,0),('ペン',100,NULL,110)) t(item,m07,m08,m09)")
print(con.sql("UNPIVOT sales_wide ON m07, m08, m09 INTO NAME month VALUE amount ORDER BY item, month"))
print(con.sql("SELECT * FROM sales_wide UNPIVOT INCLUDE NULLS (amount FOR month IN (m07, m08, m09)) "
              "ORDER BY item, month"))

1つ目(既定)は NULL のセルを除いた7行、2つ目は INCLUDE NULLS で NULL を含む9行が返りました。

┌──────────┬─────────┬────────┐
│   item   │  month  │ amount │
│ varchar  │ varchar │ int32  │
├──────────┼─────────┼────────┤
│ ノート   │ m07     │    120 │
│ ノート   │ m08     │    150 │
│ ノート   │ m09     │     90 │
│ ファイル │ m08     │     60 │
│ ファイル │ m09     │      0 │
│ ペン     │ m07     │    100 │
│ ペン     │ m09     │    110 │
└──────────┴─────────┴────────┘

なお UNPIVOT INCLUDE NULLS sales_wide ON ... と簡易構文に INCLUDE NULLS を付けると Parser Error: syntax error at or near "sales_wide" になりました。NULL を残すときは SQL標準形(FROM ... UNPIVOT INCLUDE NULLS (...))で書きます。

SQL Server・Oracle・BigQuery の構文(未実行)

以下は各公式ドキュメントの構文に沿った例で、この記事の環境では実行していません。

-- SQL Server
SELECT item, [2026-07], [2026-08], [2026-09]
  FROM (SELECT item, month, amount FROM sales) AS s
 PIVOT (SUM(amount) FOR month IN ([2026-07], [2026-08], [2026-09])) AS p;

SELECT item, month, amount
  FROM sales_wide
UNPIVOT (amount FOR month IN (m07, m08, m09)) AS u;   -- NULL のセルは出力されない
-- Oracle Database
SELECT *
  FROM (SELECT item, month, amount FROM sales)
 PIVOT (SUM(amount) FOR month IN ('2026-07' AS m07, '2026-08' AS m08, '2026-09' AS m09));

SELECT *
  FROM sales_wide
UNPIVOT INCLUDE NULLS (amount FOR month IN (m07 AS '2026-07', m08 AS '2026-08', m09 AS '2026-09'));
-- BigQuery
SELECT *
  FROM (SELECT item, month, amount FROM sales)
 PIVOT (SUM(amount) FOR month IN ('2026-07' AS m07, '2026-08' AS m08, '2026-09' AS m09));

SELECT *
  FROM sales_wide
UNPIVOT (amount FOR month IN (m07, m08, m09));

PIVOT 句がある DB でも、IN の中の列は書いて指定する必要があります(DuckDB の簡易構文を除く)。列を自動で増やしたいときは、次の動的ピボットを使います。

列が動的に増える場合(動的ピボット)

月が増えるたびにSQLを書き換えずに済むよう、列の部分のSQLを実行時に組み立てます。ここでは ('ノート','2026-10',70) を1行追加して試しました。

MySQL:GROUP_CONCAT で組み立てて PREPARE

INSERT INTO sales VALUES ('ノート','2026-10',70);

SET SESSION group_concat_max_len = 1000000;   -- 既定の1024文字では列が多いと途中で切れる
SELECT GROUP_CONCAT(DISTINCT
         CONCAT('SUM(CASE WHEN month = ', QUOTE(month), ' THEN amount ELSE 0 END) AS `', month, '`')
         ORDER BY month SEPARATOR ', ')
  INTO @cols FROM sales;
SET @sql = CONCAT('SELECT item, ', @cols, ', SUM(amount) AS total FROM sales GROUP BY item ORDER BY item');

PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
+--------------+---------+---------+---------+---------+-------+
| item         | 2026-07 | 2026-08 | 2026-09 | 2026-10 | total |
+--------------+---------+---------+---------+---------+-------+
| ノート       |     120 |     150 |      90 |      70 |   430 |
| ファイル     |       0 |      60 |       0 |       0 |    60 |
| ペン         |     100 |       0 |     110 |       0 |   210 |
+--------------+---------+---------+---------+---------+-------+

組み立てられたSQL(SELECT @sql\G の結果)は次のとおりです。

@sql: SELECT item, SUM(CASE WHEN month = '2026-07' THEN amount ELSE 0 END) AS `2026-07`, SUM(CASE WHEN month = '2026-08' THEN amount ELSE 0 END) AS `2026-08`, SUM(CASE WHEN month = '2026-09' THEN amount ELSE 0 END) AS `2026-09`, SUM(CASE WHEN month = '2026-10' THEN amount ELSE 0 END) AS `2026-10`, SUM(amount) AS total FROM sales GROUP BY item ORDER BY item

値は QUOTE() で文字列リテラルにしてから埋め込むと、値に ' が含まれていてもSQLが崩れません。列名に使う値も、利用者が自由に入力できるものではなく、テーブル内の値に限るのが安心です。

PostgreSQL:format + string_agg で組み立てて \gexec

INSERT INTO sales VALUES ('ノート','2026-10',70);

SELECT format(
  'SELECT item, %s, SUM(amount) AS total FROM sales GROUP BY item ORDER BY item',
  string_agg(format('COALESCE(SUM(amount) FILTER (WHERE month = %L), 0) AS %I', month, month),
             ', ' ORDER BY month)
) FROM (SELECT DISTINCT month FROM sales) m
\gexec
   item   | 2026-07 | 2026-08 | 2026-09 | 2026-10 | total
----------+---------+---------+---------+---------+-------
 ノート   |     120 |     150 |      90 |      70 |   430
 ファイル |       0 |      60 |       0 |       0 |    60
 ペン     |     100 |       0 |     110 |       0 |   210
(3 rows)

format の %L は値を安全な文字列リテラルに、%I は識別子(列名)にクォートして埋め込みます。\gexec は psql のメタコマンドで、直前の結果をSQLとして実行します。アプリから使う場合は、組み立てたSQL文字列を受け取って実行するか、PL/pgSQL 関数で RETURN QUERY EXECUTE を使います(戻り値の列定義が固定になる点に注意)。

pandas の pivot_table で変換する

列が動的に増える帳票は、SQLは縦持ちのまま取得し、アプリ側で横持ちにするのがいちばん簡単です(Python 3.14.7、pandas 3.0.6 で実行)。

import pandas as pd

sales = pd.DataFrame({
    "item":   ["ノート", "ノート", "ノート", "ペン", "ペン", "ペン", "ファイル", "ファイル", "ノート"],
    "month":  ["2026-07", "2026-08", "2026-09", "2026-07", "2026-07", "2026-09", "2026-08", "2026-09", "2026-10"],
    "amount": [120, 150, 90, 80, 20, 110, 60, 0, 70],
})

# 縦 → 横(列は month の値から自動で作られる)
wide = sales.pivot_table(index="item", columns="month", values="amount",
                         aggfunc="sum", fill_value=0, margins=True, margins_name="合計")
print(wide)

# 横 → 縦
wide2 = sales.pivot_table(index="item", columns="month", values="amount", aggfunc="sum").reset_index()
long = (wide2.melt(id_vars="item", var_name="month", value_name="amount")
           .dropna().astype({"amount": "int64"}))
print(long.sort_values(["item", "month"]).to_string(index=False))

# pivot(集約なし)は重複があるとエラー
try:
    sales.pivot(index="item", columns="month", values="amount")
except ValueError as e:
    print("ValueError:", e)
month  2026-07  2026-08  2026-09  2026-10   合計
item
ノート        120      150       90       70  430
ファイル         0       60        0        0   60
ペン         100        0      110        0  210
合計         220      210      200       70  700
item   month  amount
 ノート 2026-07     120
 ノート 2026-08     150
 ノート 2026-09      90
 ノート 2026-10      70
ファイル 2026-08      60
ファイル 2026-09       0
  ペン 2026-07     100
  ペン 2026-09     110
ValueError: Index contains duplicate entries, cannot reshape

margins=True で合計行と合計列が同時に付きます。pivot() は集約しないため、ペンの7月のように同じセルに2行あると ValueError: Index contains duplicate entries, cannot reshape になります。集約が必要なら pivot_table()、1セル1行が保証されているなら pivot() と使い分けます。pandas 全般の書き方は別記事「pandasの使い方 逆引き81選」にまとめています。

動的ピボットの方法の選び方

方法 向いている場面 気をつけたい点
MySQL PREPARE DB内で完結させたい、ストアドプロシージャで帳票を出す group_concat_max_len、値のクォート
PostgreSQL 動的SQL psql で手早く確認、関数化 関数の戻り値の列は固定
pandas / アプリ側 列数が読めない帳票、CSV・Excel 出力 データ量が大きいときはSQLで集約してから渡す
BIツール・表計算 人が見る集計表 元データは縦持ちで渡す

NULL と 0 の扱い

ピボットの結果に出る NULL は「データがない」、0 は「データはあるが値が0」という意味です。区別したいか、0 にそろえたいかで書き方を選びます。

書き方 データなし(ペン8月) 売上0(ファイル9月)
SUM(CASE WHEN ... THEN amount END) NULL 0
SUM(CASE WHEN ... THEN amount ELSE 0 END) 0 0
COALESCE(SUM(CASE WHEN ... THEN amount END), 0) 0 0
PG SUM(amount) FILTER (WHERE ...) NULL 0
pandas pivot_table(..., fill_value=0) 0 0

AVG と COUNT では ELSE 0 が結果を変える

SUM では ELSE 0 を付けても合計は変わりませんが、AVG では0も1件として数えられるため値が変わります。

-- MySQL 8.0.46
SELECT item,
       COUNT(CASE WHEN month = '2026-07' THEN 1 END)          AS cnt07,
       AVG(CASE WHEN month = '2026-09' THEN amount END)        AS avg09_null,
       AVG(CASE WHEN month = '2026-09' THEN amount ELSE 0 END) AS avg09_zero
  FROM sales GROUP BY item ORDER BY item;
+--------------+-------+------------+------------+
| item         | cnt07 | avg09_null | avg09_zero |
+--------------+-------+------------+------------+
| ノート       |     1 |    90.0000 |    30.0000 |
| ファイル     |     0 |     0.0000 |     0.0000 |
| ペン         |     2 |   110.0000 |    36.6667 |
+--------------+-------+------------+------------+

ノートの9月は1行(90)なので平均は 90 ですが、ELSE 0 を付けると他の月の行も0として数えられ、90÷3=30 になりました。AVG・COUNT では ELSE を付けず、表示のときに COALESCE するのが正しい値を保つコツです。COUNT(CASE ... THEN 1 END) は該当なしなら 0 を返します(NULL にはなりません)。NULL 全般の扱いは別記事「SQLのNULLの扱い」で詳しく扱っています。

アンピボットでの NULL

方法 NULL のセル
UNION ALL / LATERAL / JSON_TABLE / jsonb_each_text 行として残る(必要なら WHERE amount IS NOT NULL)
DuckDB・SQL Server・Oracle・BigQuery の UNPIVOT 既定で除外(Oracle・BigQuery・DuckDB は INCLUDE NULLS で残せる)
pandas melt 行として残る(dropna() で除外)

合計行・合計列の付け方

合計列は SUM(amount) AS total を足すだけ、合計行は ROLLUP で付けます。

MySQL:WITH ROLLUP + GROUPING()

SELECT IF(GROUPING(item) = 1, '合計', item) AS item,
       SUM(CASE WHEN month = '2026-07' THEN amount ELSE 0 END) AS m07,
       SUM(CASE WHEN month = '2026-08' THEN amount ELSE 0 END) AS m08,
       SUM(CASE WHEN month = '2026-09' THEN amount ELSE 0 END) AS m09,
       SUM(amount) AS total
  FROM sales
 GROUP BY item WITH ROLLUP;
+--------------+------+------+------+-------+
| item         | m07  | m08  | m09  | total |
+--------------+------+------+------+-------+
| ノート       |  120 |  150 |   90 |   360 |
| ファイル     |    0 |   60 |    0 |    60 |
| ペン         |  100 |    0 |  110 |   210 |
| 合計         |  220 |  210 |  200 |   630 |
+--------------+------+------+------+-------+

PostgreSQL:GROUP BY ROLLUP + GROUPING()

SELECT CASE WHEN GROUPING(item) = 1 THEN '合計' ELSE item END AS item,
       COALESCE(SUM(amount) FILTER (WHERE month = '2026-07'), 0) AS m07,
       COALESCE(SUM(amount) FILTER (WHERE month = '2026-08'), 0) AS m08,
       COALESCE(SUM(amount) FILTER (WHERE month = '2026-09'), 0) AS m09,
       SUM(amount) AS total
  FROM sales
 GROUP BY ROLLUP (item)
 ORDER BY GROUPING(item), item;
   item   | m07 | m08 | m09 | total
----------+-----+-----+-----+-------
 ノート   | 120 | 150 |  90 |   360
 ファイル |   0 |  60 |   0 |    60
 ペン     | 100 |   0 | 110 |   210
 合計     | 220 | 210 | 200 |   630
(4 rows)

GROUPING(item) は合計行で 1 を返す関数です。item IS NULL で判定すると、item に本物の NULL がある場合に区別できなくなるため、GROUPING を使うのが確実です。PostgreSQL では ORDER BY GROUPING(item), item で合計行を最後に置いています。

やりたいこと MySQL PostgreSQL
合計列 SUM(amount) AS total 同左
合計行 GROUP BY item WITH ROLLUP GROUP BY ROLLUP (item)
合計行に「合計」と表示 IF(GROUPING(item)=1,'合計',item) CASE WHEN GROUPING(item)=1 THEN '合計' ELSE item END
小計と総計(2階層) GROUP BY 分類, item WITH ROLLUP GROUP BY ROLLUP (分類, item)
合計行を最後に並べる ROLLUP の行は各グループの後ろに出る ORDER BY GROUPING(item), item

よくある質問

Q. MySQL に PIVOT 句はありますか? A. MySQL 8.0・8.4・9.7 には PIVOT / UNPIVOT 句はありません。縦→横は SUM(CASE WHEN ...)、横→縦は UNION ALL か CROSS JOIN LATERAL(8.0.14以降)で書きます。

Q. PostgreSQL では FILTER と crosstab のどちらを使えばよいですか? A. 列が十数個までなら、拡張不要で読みやすい FILTER(または CASE)が扱いやすいです。列が非常に多い場合や、元のSQLを短く保ちたい場合は crosstab が向いています。crosstab は2引数版を使うと、欠けている月があっても列がずれません。

Q. 列名を月ごとに自動で増やしたいです。 A. SQLだけで行うなら、MySQL は GROUP_CONCAT + PREPARE、PostgreSQL は format + string_agg で列部分を組み立てます。帳票やCSV出力が目的なら、縦持ちのまま取得して pandas の pivot_table で変換するのが簡単です。

Q. 横持ちのテーブルを縦持ちに作り直したいです。 A. INSERT INTO 新テーブル (item, month, amount) SELECT ... の SELECT 部分に、この記事の UNION ALL や LATERAL のSQLをそのまま使えます。NULL のセルを入れたくない場合は WHERE amount IS NOT NULL を付けます。

まとめ

  • 縦→横は「CASE+SUM+GROUP BY」がどのDBでも通じる基本形。PostgreSQL は FILTER 句でより読みやすく書ける
  • PostgreSQL の crosstab は2引数版で使う。1引数版は欠けた月があると値が左詰めになる
  • 横→縦は UNION ALL が基本、LATERAL ならテーブルを1回読むだけ。PG の jsonb_each_text は列名を書かずに展開できる
  • MySQL・PostgreSQL に PIVOT 句はない。SQL Server・Oracle・BigQuery・DuckDB は PIVOT / UNPIVOT 句がある
  • 列が動的に増えるなら、MySQL は PREPARE、PG は format+\gexec、帳票なら pandas の pivot_table が手軽
  • NULL と0は意味が違う。SUM なら ELSE 0 で問題ないが、AVG では値が変わるので COALESCE を表示側で使う
  • 合計行は ROLLUP+GROUPING()、合計列は SUM(amount) を追加する

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「SQLで縦持ち⇔横持ち変換|MySQL・PostgreSQL別コピペ例と対応表」

株式会社RJCでは、SQLでのデータ集計の書き方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?