データは「縦長」で持ち、「横長」で見る
SQLのテーブルは基本的に縦長(ロング形式)で設計される。月次売上なら「1行 = 1カテゴリ × 1ヶ月」という形だ。これは集計しやすく、行を追加するだけで新しいデータを格納できる。
しかし人間がレポートを読むときは「横長(ワイド形式)」が直感的に分かりやすい。月を列に並べることで、カテゴリごとの月次推移が一目で見える。
| カテゴリ | 月 | 売上 |
| ヘアケア | 10月 | 3,200,000 |
| ヘアケア | 11月 | 3,800,000 |
| ヘアケア | 12月 | 4,100,000 |
| スキンケア | 10月 | 2,400,000 |
| スキンケア | 11月 | 2,900,000 |
| スキンケア | 12月 | 3,200,000 |
| カテゴリ | 10月 | 11月 | 12月 |
| ヘアケア | 3,200,000 | 3,800,000 | 4,100,000 |
| スキンケア | 2,400,000 | 2,900,000 | 3,200,000 |
この変換を「ピボット(縦→横)」、逆方向を「アンピボット(横→縦)」と呼ぶ。
SQL Server や BigQuery には PIVOT / UNPIVOT 構文が存在するが、や多くの標準 SQL エンジンにはない。代わりに CASE WHEN を使って実装する方法を今回は完全に解説する。
使用するテーブル
-- monthly_category_sales(月次カテゴリ別売上:縦長形式)
-- category : カテゴリ名
-- sales_month : 集計月(DATE型、月初の日付)
-- total_sales : 売上金額
-- order_count : 注文件数| category | sales_month | total_sales | order_count |
|---|---|---|---|
| ヘアケア | 2024-10-01 | 3,200,000 | 284 |
| ヘアケア | 2024-11-01 | 3,800,000 | 341 |
| ヘアケア | 2024-12-01 | 4,100,000 | 378 |
| スキンケア | 2024-10-01 | 2,400,000 | 198 |
| スキンケア | 2024-11-01 | 2,900,000 | 241 |
| スキンケア | 2024-12-01 | 3,200,000 | 276 |
| ボディケア | 2024-10-01 | 1,800,000 | 156 |
| ボディケア | 2024-11-01 | 2,100,000 | 182 |
| ボディケア | 2024-12-01 | 2,400,000 | 208 |
Part 1:ピボット(縦→横変換)
STEP 1 ― CASE WHEN で月を列に展開する
各月を1列にするには「その月のデータであれば値を返し、そうでなければ NULL を返す」CASE WHEN を月ごとに書き、MAX(または SUM)で集約する。
-- STEP1: 3ヶ月分を横展開する基本ピボット
SELECT
category,
-- 10月の売上
MAX(CASE WHEN sales_month = DATE '2024-10-01' THEN total_sales END) AS "2024_10",
-- 11月の売上
MAX(CASE WHEN sales_month = DATE '2024-11-01' THEN total_sales END) AS "2024_11",
-- 12月の売上
MAX(CASE WHEN sales_month = DATE '2024-12-01' THEN total_sales END) AS "2024_12"
FROM monthly_category_sales
GROUP BY category
ORDER BY category;出力イメージ
| category | 2024_10 | 2024_11 | 2024_12 |
|---|---|---|---|
| ヘアケア | 3,200,000 | 3,800,000 | 4,100,000 |
| スキンケア | 2,400,000 | 2,900,000 | 3,200,000 |
| ボディケア | 1,800,000 | 2,100,000 | 2,400,000 |
MAXとSUMの使い分け
1つの category × month の組み合わせに必ず1行しかない場合はMAXでもSUMでも結果は同じだ。しかし集計前のデータ(行が重複している可能性がある)に直接ピボットをかける場合はSUMを使う。今回のような「すでに集計済みの縦長テーブル」にはMAXが直感的でシンプルだ。
STEP 2 ― 元データから直接ピボットする(orders テーブルから)
集計済みテーブルがなく、orders と order_items から直接ピボットするパターンも実務では多い。この場合は SUM を使う。
-- STEP2: 元データから直接ピボット(SUM を使う)
SELECT
oi.category,
SUM(CASE
WHEN DATE_TRUNC('month', o.order_date) = DATE '2024-10-01'
THEN oi.quantity * oi.unit_price
ELSE 0
END) AS "2024_10",
SUM(CASE
WHEN DATE_TRUNC('month', o.order_date) = DATE '2024-11-01'
THEN oi.quantity * oi.unit_price
ELSE 0
END) AS "2024_11",
SUM(CASE
WHEN DATE_TRUNC('month', o.order_date) = DATE '2024-12-01'
THEN oi.quantity * oi.unit_price
ELSE 0
END) AS "2024_12",
-- 合計列も一緒に出す
SUM(oi.quantity * oi.unit_price) AS "合計"
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
WHERE
o.status = 'completed'
AND o.order_date >= DATE '2024-10-01'
AND o.order_date < DATE '2025-01-01'
GROUP BY oi.category
ORDER BY 合計 DESC;出力イメージ
| category | 2024_10 | 2024_11 | 2024_12 | 合計 |
|---|---|---|---|---|
| ヘアケア | 3,200,000 | 3,800,000 | 4,100,000 | 11,100,000 |
| スキンケア | 2,400,000 | 2,900,000 | 3,200,000 | 8,500,000 |
| ボディケア | 1,800,000 | 2,100,000 | 2,400,000 | 6,300,000 |
STEP 3 ― 前月比も同時に出す(完成版ピボット)
ピボットしながら前月比も一緒に計算する。月の列と成長率の列を交互に並べることで、レポートとして完結する。
-- STEP3: 売上と前月比を交互に並べた完成版ピボット
WITH monthly_pivot AS (
SELECT
oi.category,
SUM(CASE WHEN DATE_TRUNC('month', o.order_date) = DATE '2024-10-01'
THEN oi.quantity * oi.unit_price ELSE 0 END) AS m10,
SUM(CASE WHEN DATE_TRUNC('month', o.order_date) = DATE '2024-11-01'
THEN oi.quantity * oi.unit_price ELSE 0 END) AS m11,
SUM(CASE WHEN DATE_TRUNC('month', o.order_date) = DATE '2024-12-01'
THEN oi.quantity * oi.unit_price ELSE 0 END) AS m12
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
WHERE o.status = 'completed'
AND o.order_date >= DATE '2024-10-01'
AND o.order_date < DATE '2025-01-01'
GROUP BY oi.category
)
SELECT
category,
m10 AS "10月売上",
m11 AS "11月売上",
ROUND((m11 - m10) * 100.0 / NULLIF(m10, 0), 1) AS "11月前月比%",
m12 AS "12月売上",
ROUND((m12 - m11) * 100.0 / NULLIF(m11, 0), 1) AS "12月前月比%",
m10 + m11 + m12 AS "3ヶ月合計"
FROM monthly_pivot
ORDER BY "3ヶ月合計" DESC;完成した出力イメージ
| category | 10月売上 | 11月売上 | 11月前月比% | 12月売上 | 12月前月比% | 3ヶ月合計 |
|---|---|---|---|---|---|---|
| ヘアケア | 3,200,000 | 3,800,000 | +18.8 | 4,100,000 | +7.9 | 11,100,000 |
| スキンケア | 2,400,000 | 2,900,000 | +20.8 | 3,200,000 | +10.3 | 8,500,000 |
| ボディケア | 1,800,000 | 2,100,000 | +16.7 | 2,400,000 | +14.3 | 6,300,000 |
Part 2:アンピボット(横→縦変換)
横長のデータを縦長に戻す操作だ。BIツールが縦長形式を要求するとき、Excelから受け取ったレポートをDBに取り込むとき、横長テーブルに GROUP BY をかけたいときなどに使う。
STEP 4 ― CROSS JOIN + CASE WHEN でアンピボット
横長テーブルから縦長に変換する標準的なアプローチは CROSS JOIN + 「どの列を取り出すか」の制御だ。
-- STEP4: 横長テーブルを縦長に変換する(アンピボット)
-- 元の横長テーブル(monthly_pivot_wide)
-- category | 2024_10 | 2024_11 | 2024_12
-- ヘアケア | 3,200,000 | 3,800,000 | 4,100,000
WITH wide_table AS (
-- 上記のSTEP3のmonthly_pivotをそのまま利用
SELECT
oi.category,
SUM(CASE WHEN DATE_TRUNC('month', o.order_date) = DATE '2024-10-01'
THEN oi.quantity * oi.unit_price ELSE 0 END) AS m10,
SUM(CASE WHEN DATE_TRUNC('month', o.order_date) = DATE '2024-11-01'
THEN oi.quantity * oi.unit_price ELSE 0 END) AS m11,
SUM(CASE WHEN DATE_TRUNC('month', o.order_date) = DATE '2024-12-01'
THEN oi.quantity * oi.unit_price ELSE 0 END) AS m12
FROM orders o JOIN order_items oi ON o.order_id = oi.order_id
WHERE o.status = 'completed'
AND o.order_date >= DATE '2024-10-01'
AND o.order_date < DATE '2025-01-01'
GROUP BY oi.category
),
-- 月のリストをCROSS JOINで展開
month_list AS (
SELECT 1 AS month_id, DATE '2024-10-01' AS sales_month, '2024_10' AS month_label
UNION ALL
SELECT 2, DATE '2024-11-01', '2024_11'
UNION ALL
SELECT 3, DATE '2024-12-01', '2024_12'
)
SELECT
w.category,
m.sales_month,
m.month_label,
-- month_idに応じて対応する列の値を取り出す
CASE m.month_id
WHEN 1 THEN w.m10
WHEN 2 THEN w.m11
WHEN 3 THEN w.m12
END AS total_sales
FROM wide_table w
CROSS JOIN month_list m
ORDER BY w.category, m.month_id;出力イメージ(縦長形式に戻った)
| category | sales_month | total_sales |
|---|---|---|
| ヘアケア | 2024-10-01 | 3,200,000 |
| ヘアケア | 2024-11-01 | 3,800,000 |
| ヘアケア | 2024-12-01 | 4,100,000 |
| スキンケア | 2024-10-01 | 2,400,000 |
| スキンケア | 2024-11-01 | 2,900,000 |
| スキンケア | 2024-12-01 | 3,200,000 |
元の縦長形式に戻った。CROSS JOIN × CASE のパターンがアンピボットの定石だ。
動的列数への対応
CASE WHEN によるピボットの最大の弱点は「列数があらかじめ決まっていないと書けない」ことだ。月数が固定なら問題ないが、「直近N ヶ月を動的に展開したい」という要件には対応が難しい。
現実的な対処法は2つある。
対処法1:列数の上限を決めて CASE WHEN を多めに書いておく
-- 直近12ヶ月分のCASE WHENを書いておき、
-- 対象期間外はNULLになる(BIツール側でNULL列を非表示にする)
SELECT
category,
MAX(CASE WHEN sales_month = DATE_ADD('month', -11, DATE_TRUNC('month', CURRENT_DATE))
THEN total_sales END) AS m_minus_11,
MAX(CASE WHEN sales_month = DATE_ADD('month', -10, DATE_TRUNC('month', CURRENT_DATE))
THEN total_sales END) AS m_minus_10,
-- ... m_minus_9 〜 m_minus_1 ...
MAX(CASE WHEN sales_month = DATE_TRUNC('month', CURRENT_DATE)
THEN total_sales END) AS m_current
FROM monthly_category_sales
GROUP BY category;対処法2:縦長形式のままBIツールに渡してピボットはBIツール側で行う
Tableau・Looker Studio・Metabase などのBIツールはピボット機能を持っている。SQLはデータを縦長で出力することに徹し、横展開はBIツールに任せるほうが柔軟性が高く、運用コストも低い。
実務では「固定期間のレポートにはSQLでピボット」「動的期間の分析にはBIツールでピボット」と使い分けるのが現実的だ。
ピボットとコホートテーブルの関係
実は No.4(コホート分析)の STEP4 で書いた「ピボット形式のコホートテーブル」は、今回の CASE WHEN ピボットそのものだった。
-- No.4のコホートピボット(再掲・今回の手法との共通点)
SELECT
cohort_month,
MAX(CASE WHEN month_number = 0 THEN retention_rate END) AS "M+0",
MAX(CASE WHEN month_number = 1 THEN retention_rate END) AS "M+1",
MAX(CASE WHEN month_number = 2 THEN retention_rate END) AS "M+2",
-- ...
FROM retention
GROUP BY cohort_month;「month_number という数値を列名に変換して横展開する」のが CASE WHEN ピボットの本質だ。コホートテーブル・ヒートマップ・クロス集計表など、あらゆる「行列形式の出力」が同じパターンで実現できる。
実務での運用ヒント
① NULL と 0 の使い分け
MAX(CASE WHEN ... THEN total_sales END) で対象データがない場合は NULL が返る。「売上ゼロ」と「データなし(その月にそのカテゴリの注文がなかった)」を区別したい場合は NULL のままにする。区別不要なら COALESCE(..., 0) で 0 に変換する。
② 列名に日付文字列を使う場合はクォートが必要
"2024_10" のように数字で始まる列名や特殊文字を含む列名はダブルクォートで囲む必要がある。Presto では "2024_10" は有効だが、2024-10 のようなハイフンを含む列名はエラーになる。アンダースコアに変換するか、m10 のような短いエイリアスを使うのが安全だ。
③ 12ヶ月以上のピボットはSQL以外の選択肢を検討する
12ヶ月 × 複数カテゴリを CASE WHEN で全部書くと、クエリが数百行になる。メンテナンス性が著しく落ちるため、Pythonの pandas.pivot_table やBIツールのピボット機能に委ねたほうが現実的だ。SQLの CASE WHEN ピボットは「3〜6列程度の固定レポート」に向いている。
まとめ
| 変換の方向 | 手法 | 核心パターン |
|---|---|---|
| 縦→横(ピボット) | SUM(CASE WHEN col = 'X' THEN val END) + GROUP BY | 月や区分を「列名」に変換 |
| 横→縦(アンピボット) | CROSS JOIN month_list + CASE month_id WHEN N THEN col | 列を「行」に展開 |
ピボットは「どの列をGROUP BYの軸にし、どの値を列方向に展開するか」を明確に決めてから書き始めると迷わない。アンピボットは「展開したい列の一覧を month_list のようなCTEで用意し、CROSS JOIN する」という構造を覚えると応用が利く。