「売上データを月別の横並び表にしたい」 「縦長のデータを見やすい表形式に変換したい」 「ExcelのピボットテーブルみたいなことをSQLでやりたい」
データベースのデータは通常、縦長(行形式)で保存されています。でも、レポートや分析では横長(列形式)の方が見やすいことが多いですよね。
PostgreSQLでは、この「行を列に」「列を行に」変換する方法がいくつかあります。最初は難しく感じるかもしれませんが、パターンを覚えてしまえば簡単です。
この記事では、PostgreSQLでの行列変換について、基本的なCASE文から、強力なCROSSTAB関数まで、実例を交えながら分かりやすく解説します。これを読めば、あなたもデータを自在に変形できるようになりますよ!
行列変換の基本概念

そもそも行列変換とは?
行から列への変換(ピボット):
変換前(縦長): 変換後(横長):
月 | 商品 | 売上 商品 | 1月 | 2月 | 3月
------|-------|------ ------|------|------|------
1月 | A | 100 A | 100 | 150 | 120
2月 | A | 150 B | 200 | 180 | 220
3月 | A | 120
1月 | B | 200
2月 | B | 180
3月 | B | 220
縦に並んでいたデータが、横に展開されます。
なぜ行列変換するの?
メリット:
- レポートが見やすくなる
- Excelとの連携が簡単
- 比較分析がしやすい
- グラフ化しやすい
- データの傾向が分かりやすい
特に時系列データや、カテゴリー別の集計で威力を発揮します。
サンプルデータの準備
テストテーブルの作成
まず、実例で使うサンプルデータを用意しましょう。
-- 売上テーブルの作成
CREATE TABLE sales (
id SERIAL PRIMARY KEY,
sales_date DATE,
product VARCHAR(50),
category VARCHAR(50),
amount DECIMAL(10,2)
);
-- サンプルデータの挿入
INSERT INTO sales (sales_date, product, category, amount) VALUES
('2024-01-15', 'ノートPC', '電子機器', 150000),
('2024-01-20', 'マウス', '周辺機器', 3000),
('2024-02-10', 'ノートPC', '電子機器', 180000),
('2024-02-15', 'キーボード', '周辺機器', 8000),
('2024-03-05', 'ノートPC', '電子機器', 160000),
('2024-03-20', 'マウス', '周辺機器', 3500),
('2024-01-25', 'モニター', '電子機器', 45000),
('2024-02-28', 'モニター', '電子機器', 48000),
('2024-03-15', 'キーボード', '周辺機器', 8500);
このデータを使って、様々な変換方法を学んでいきます。
CASE文を使った基本的なピボット
シンプルなピボット
最も基本的な方法は、CASE文を使うことです。
-- 月別・商品別の売上集計(横展開)
SELECT
product AS "商品",
SUM(CASE WHEN EXTRACT(MONTH FROM sales_date) = 1
THEN amount ELSE 0 END) AS "1月",
SUM(CASE WHEN EXTRACT(MONTH FROM sales_date) = 2
THEN amount ELSE 0 END) AS "2月",
SUM(CASE WHEN EXTRACT(MONTH FROM sales_date) = 3
THEN amount ELSE 0 END) AS "3月",
SUM(amount) AS "合計"
FROM sales
WHERE EXTRACT(YEAR FROM sales_date) = 2024
GROUP BY product
ORDER BY product;
結果:
商品 | 1月 | 2月 | 3月 | 合計
------------|--------|--------|--------|--------
キーボード | 0 | 8000 | 8500 | 16500
ノートPC | 150000 | 180000 | 160000 | 490000
マウス | 3000 | 0 | 3500 | 6500
モニター | 45000 | 48000 | 0 | 93000
複数条件でのピボット
カテゴリー別の集計も同時に行う場合:
-- カテゴリー別・月別の売上集計
SELECT
category AS "カテゴリー",
COUNT(CASE WHEN EXTRACT(MONTH FROM sales_date) = 1
THEN 1 END) AS "1月件数",
SUM(CASE WHEN EXTRACT(MONTH FROM sales_date) = 1
THEN amount ELSE 0 END) AS "1月売上",
COUNT(CASE WHEN EXTRACT(MONTH FROM sales_date) = 2
THEN 1 END) AS "2月件数",
SUM(CASE WHEN EXTRACT(MONTH FROM sales_date) = 2
THEN amount ELSE 0 END) AS "2月売上",
COUNT(CASE WHEN EXTRACT(MONTH FROM sales_date) = 3
THEN 1 END) AS "3月件数",
SUM(CASE WHEN EXTRACT(MONTH FROM sales_date) = 3
THEN amount ELSE 0 END) AS "3月売上"
FROM sales
WHERE EXTRACT(YEAR FROM sales_date) = 2024
GROUP BY category
ORDER BY category;
件数と金額を同時に集計できます。
CROSSTAB関数による高度なピボット
tablefunc拡張のインストール
CROSSTABを使うには、まず拡張機能をインストールします。
-- tablefunc拡張のインストール(一度だけ実行)
CREATE EXTENSION IF NOT EXISTS tablefunc;
基本的なCROSSTABの使い方
-- CROSSTAB関数での月別商品売上
SELECT * FROM crosstab(
'SELECT product,
EXTRACT(MONTH FROM sales_date)::INT,
SUM(amount)
FROM sales
WHERE EXTRACT(YEAR FROM sales_date) = 2024
GROUP BY product, EXTRACT(MONTH FROM sales_date)
ORDER BY 1, 2',
'SELECT DISTINCT EXTRACT(MONTH FROM sales_date)::INT
FROM sales
WHERE EXTRACT(YEAR FROM sales_date) = 2024
ORDER BY 1'
) AS ct(
product VARCHAR(50),
"1月" DECIMAL(10,2),
"2月" DECIMAL(10,2),
"3月" DECIMAL(10,2)
);
CROSSTABは高速で、複雑な変換にも対応できます。
動的な列名での変換
列名を動的に生成する場合:
-- 動的SQLでCROSSTABを生成
DO $$
DECLARE
sql_query TEXT;
columns_list TEXT;
BEGIN
-- 月のリストを動的に生成
SELECT STRING_AGG(
'"' || month_num || '月" DECIMAL(10,2)',
', '
ORDER BY month_num
) INTO columns_list
FROM (
SELECT DISTINCT EXTRACT(MONTH FROM sales_date)::INT as month_num
FROM sales
WHERE EXTRACT(YEAR FROM sales_date) = 2024
) months;
-- 動的クエリの生成と実行
sql_query := format('
CREATE TEMP TABLE pivot_result AS
SELECT * FROM crosstab(
''SELECT product,
EXTRACT(MONTH FROM sales_date)::INT,
SUM(amount)
FROM sales
WHERE EXTRACT(YEAR FROM sales_date) = 2024
GROUP BY product, EXTRACT(MONTH FROM sales_date)
ORDER BY 1, 2'',
''SELECT DISTINCT EXTRACT(MONTH FROM sales_date)::INT
FROM sales
WHERE EXTRACT(YEAR FROM sales_date) = 2024
ORDER BY 1''
) AS ct(product VARCHAR(50), %s)', columns_list);
EXECUTE sql_query;
END $$;
-- 結果の確認
SELECT * FROM pivot_result;
FILTER句を使った集計(PostgreSQL 9.4以降)

FILTER句でスッキリ記述
PostgreSQL 9.4以降では、FILTER句でより読みやすく書けます。
-- FILTER句を使った月別集計
SELECT
product AS "商品",
SUM(amount) FILTER (WHERE EXTRACT(MONTH FROM sales_date) = 1) AS "1月",
SUM(amount) FILTER (WHERE EXTRACT(MONTH FROM sales_date) = 2) AS "2月",
SUM(amount) FILTER (WHERE EXTRACT(MONTH FROM sales_date) = 3) AS "3月",
COUNT(*) FILTER (WHERE amount > 10000) AS "高額取引数",
AVG(amount)::DECIMAL(10,2) AS "平均売上"
FROM sales
WHERE EXTRACT(YEAR FROM sales_date) = 2024
GROUP BY product
ORDER BY product;
CASE文よりも直感的で、パフォーマンスも良好です。
複数の集計関数との組み合わせ
-- 様々な集計を同時に実行
SELECT
category AS "カテゴリー",
-- 件数
COUNT(*) FILTER (WHERE EXTRACT(MONTH FROM sales_date) = 1) AS "1月件数",
COUNT(*) FILTER (WHERE EXTRACT(MONTH FROM sales_date) = 2) AS "2月件数",
COUNT(*) FILTER (WHERE EXTRACT(MONTH FROM sales_date) = 3) AS "3月件数",
-- 合計
SUM(amount) FILTER (WHERE EXTRACT(MONTH FROM sales_date) = 1) AS "1月合計",
SUM(amount) FILTER (WHERE EXTRACT(MONTH FROM sales_date) = 2) AS "2月合計",
SUM(amount) FILTER (WHERE EXTRACT(MONTH FROM sales_date) = 3) AS "3月合計",
-- 最大値
MAX(amount) FILTER (WHERE EXTRACT(MONTH FROM sales_date) = 1) AS "1月最大",
MAX(amount) FILTER (WHERE EXTRACT(MONTH FROM sales_date) = 2) AS "2月最大",
MAX(amount) FILTER (WHERE EXTRACT(MONTH FROM sales_date) = 3) AS "3月最大"
FROM sales
WHERE EXTRACT(YEAR FROM sales_date) = 2024
GROUP BY category;
アンピボット(列から行への変換)
UNION ALLを使った方法
横長のデータを縦長に戻す場合:
-- まず横長のテーブルを作成
CREATE TEMP TABLE monthly_sales AS
SELECT
product,
SUM(amount) FILTER (WHERE EXTRACT(MONTH FROM sales_date) = 1) AS jan,
SUM(amount) FILTER (WHERE EXTRACT(MONTH FROM sales_date) = 2) AS feb,
SUM(amount) FILTER (WHERE EXTRACT(MONTH FROM sales_date) = 3) AS mar
FROM sales
GROUP BY product;
-- UNION ALLでアンピボット
SELECT product, '1月' as month, jan as amount FROM monthly_sales
UNION ALL
SELECT product, '2月' as month, feb as amount FROM monthly_sales
UNION ALL
SELECT product, '3月' as month, mar as amount FROM monthly_sales
ORDER BY product, month;
LATERAL JOINを使った方法
より効率的なアンピボット:
-- LATERAL JOINでアンピボット
SELECT
ms.product,
v.month,
v.amount
FROM monthly_sales ms
CROSS JOIN LATERAL (
VALUES
('1月', ms.jan),
('2月', ms.feb),
('3月', ms.mar)
) AS v(month, amount)
WHERE v.amount IS NOT NULL
ORDER BY ms.product, v.month;
LATERALを使うと、コードがスッキリします。
JSON関数を活用した変換
JSONでの柔軟な変換
PostgreSQL 9.3以降では、JSON関数で柔軟な変換ができます。
-- JSON形式での集計
SELECT
product,
JSON_OBJECT_AGG(
TO_CHAR(sales_date, 'YYYY-MM'),
amount
) AS monthly_sales
FROM sales
GROUP BY product;
-- 結果をJSON形式で取得
SELECT
product,
monthly_sales->>'2024-01' AS "2024年1月",
monthly_sales->>'2024-02' AS "2024年2月",
monthly_sales->>'2024-03' AS "2024年3月"
FROM (
SELECT
product,
JSON_OBJECT_AGG(
TO_CHAR(sales_date, 'YYYY-MM'),
SUM(amount)
) AS monthly_sales
FROM sales
GROUP BY product, TO_CHAR(sales_date, 'YYYY-MM')
) sub
GROUP BY product, monthly_sales;
JSONB_AGGでの配列集計
-- 商品ごとの取引履歴を配列で集計
SELECT
product,
JSONB_AGG(
JSONB_BUILD_OBJECT(
'date', sales_date,
'amount', amount,
'category', category
) ORDER BY sales_date
) AS transaction_history
FROM sales
GROUP BY product;
JSONを使うと、階層的なデータ構造も表現できます。
パフォーマンス最適化
インデックスの活用
-- ピボット用のインデックス作成
CREATE INDEX idx_sales_date_product ON sales(sales_date, product);
CREATE INDEX idx_sales_product_amount ON sales(product, amount);
-- 部分インデックスで特定期間を高速化
CREATE INDEX idx_sales_2024 ON sales(sales_date, product, amount)
WHERE EXTRACT(YEAR FROM sales_date) = 2024;
マテリアライズドビューの活用
頻繁に使うピボットテーブルは、マテリアライズドビューにします。
-- マテリアライズドビューの作成
CREATE MATERIALIZED VIEW mv_monthly_sales AS
SELECT
product,
EXTRACT(YEAR FROM sales_date) AS year,
EXTRACT(MONTH FROM sales_date) AS month,
SUM(amount) AS total_amount,
COUNT(*) AS transaction_count,
AVG(amount) AS avg_amount
FROM sales
GROUP BY product, EXTRACT(YEAR FROM sales_date), EXTRACT(MONTH FROM sales_date);
-- インデックスの作成
CREATE INDEX idx_mv_monthly_sales ON mv_monthly_sales(year, month, product);
-- 定期的な更新
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_monthly_sales;
実践的な応用例
前年同月比の計算
-- 前年同月比を含むピボットテーブル
WITH monthly_data AS (
SELECT
EXTRACT(YEAR FROM sales_date) AS year,
EXTRACT(MONTH FROM sales_date) AS month,
product,
SUM(amount) AS amount
FROM sales
GROUP BY EXTRACT(YEAR FROM sales_date),
EXTRACT(MONTH FROM sales_date),
product
)
SELECT
product,
SUM(CASE WHEN year = 2023 AND month = 1 THEN amount END) AS "2023年1月",
SUM(CASE WHEN year = 2024 AND month = 1 THEN amount END) AS "2024年1月",
ROUND(
(SUM(CASE WHEN year = 2024 AND month = 1 THEN amount END) -
SUM(CASE WHEN year = 2023 AND month = 1 THEN amount END)) * 100.0 /
NULLIF(SUM(CASE WHEN year = 2023 AND month = 1 THEN amount END), 0),
2
) AS "1月成長率%"
FROM monthly_data
GROUP BY product;
ランキング付きピボット
-- 月別売上ランキング付きピボット
WITH ranked_sales AS (
SELECT
product,
EXTRACT(MONTH FROM sales_date) AS month,
SUM(amount) AS total,
RANK() OVER (
PARTITION BY EXTRACT(MONTH FROM sales_date)
ORDER BY SUM(amount) DESC
) AS rank
FROM sales
WHERE EXTRACT(YEAR FROM sales_date) = 2024
GROUP BY product, EXTRACT(MONTH FROM sales_date)
)
SELECT
product,
MAX(CASE WHEN month = 1 THEN total END) AS "1月売上",
MAX(CASE WHEN month = 1 THEN rank END) AS "1月順位",
MAX(CASE WHEN month = 2 THEN total END) AS "2月売上",
MAX(CASE WHEN month = 2 THEN rank END) AS "2月順位",
MAX(CASE WHEN month = 3 THEN total END) AS "3月売上",
MAX(CASE WHEN month = 3 THEN rank END) AS "3月順位"
FROM ranked_sales
GROUP BY product
ORDER BY product;
よくある質問と解決策

Q: 列数が動的に変わる場合は?
A: 動的SQLを使用するか、JSON形式で出力してアプリケーション側で処理します。PL/pgSQLでプロシージャを作成するのも有効です。
Q: NULLを0として表示したい
A: COALESCE関数を使用します:
COALESCE(SUM(amount) FILTER (WHERE month = 1), 0) AS "1月"
Q: CROSSTABとCASE文、どちらが速い?
A: 一般的にCROSSTABの方が高速ですが、単純な変換ならCASE文でも十分です。データ量とクエリの複雑さで判断しましょう。
Q: 大量データでメモリ不足になる
A: パーティション化、インデックスの最適化、マテリアライズドビューの活用を検討してください。
Q: 日付の範囲を動的に指定したい
A: パラメータ化クエリやプリペアドステートメントを使用します。
まとめ:状況に応じた最適な方法を選ぼう
PostgreSQLでの行列変換は、様々な方法があります。
方法の選び方:
- シンプルな変換:CASE文またはFILTER句
- 複雑な変換:CROSSTAB関数
- 動的な列:動的SQLまたはJSON関数
- 逆変換:UNION ALLまたはLATERAL JOIN
- 高頻度アクセス:マテリアライズドビュー
ベストプラクティス:
- まずシンプルな方法から試す
- パフォーマンスを測定して比較
- 可読性とメンテナンス性を重視
- 適切なインデックスを設定
- 定期的にVACUUMとANALYZEを実行
この記事で紹介した技術を使えば、どんなデータ変換要求にも対応できるはずです。最初は簡単なCASE文から始めて、徐々に高度な技術にチャレンジしてみてください。
データの見せ方を変えるだけで、新しい発見があるかもしれません。さあ、あなたのデータを自在に変換して、価値ある情報を引き出しましょう!
コメント