- SQL
SQLで縦持ち⇔横持ち変換|MySQL・PostgreSQL別コピペ例と対応表
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期連続の黒字決算。