データベースで同じ値を持つ行が複数ある場合、それらをまとめて合計を求めたいことがよくあります。例えば「商品ごとの売上合計」「部署ごとの人件費合計」「月ごとの売上推移」などです。
SQLでは GROUP BY と SUM() を組み合わせることで、特定のカラムの値が重複するデータを加算できます。この記事では、基本的な使い方からHAVING・複数カラムでの集計・ROLLUP(小計・総計)・ウィンドウ関数(SUM OVER)・条件付き集計まで、実務で必要なパターンを体系的に解説します。
この記事で学べること
- GROUP BY + SUM() で重複データを加算する基本
- HAVING句で集計結果を絞り込む方法
- 複数カラムでのグループ化と集計
- ROLLUP / CUBE で小計・総計を追加する方法
- SUM() OVER (PARTITION BY …) ウィンドウ関数との違い
- SUM(CASE …) で条件付き集計を行う方法
- NULL対策・パフォーマンス・RDBMS別の注意点
サンプルデータの準備
以下のサンプルテーブルを使って解説します。
▶ CREATE TABLE + INSERT文(クリックで展開)
CREATE TABLE sales (
id INT PRIMARY KEY,
sale_date DATE,
category VARCHAR(20),
product VARCHAR(30),
amount INT,
quantity INT
);
INSERT INTO sales VALUES
(1, '2024-01-15', '食品', 'りんご', 300, 5),
(2, '2024-01-20', '食品', 'みかん', 200, 8),
(3, '2024-02-10', '食品', 'りんご', 300, 3),
(4, '2024-02-15', '飲料', 'お茶', 150, 10),
(5, '2024-03-05', '飲料', 'コーヒー', 200, 6),
(6, '2024-03-10', '食品', 'みかん', 200, 4),
(7, '2024-03-20', '飲料', 'お茶', 150, 7),
(8, '2024-04-01', '日用品', '石鹸', 400, 2),
(9, '2024-04-10', '食品', 'りんご', NULL, 1),
(10, '2024-04-15', '飲料', 'コーヒー', 200, 5);
| id |
sale_date |
category |
product |
amount |
quantity |
| 1 |
2024-01-15 |
食品 |
りんご |
300 |
5 |
| 2 |
2024-01-20 |
食品 |
みかん |
200 |
8 |
| 3 |
2024-02-10 |
食品 |
りんご |
300 |
3 |
| 4 |
2024-02-15 |
飲料 |
お茶 |
150 |
10 |
| 5 |
2024-03-05 |
飲料 |
コーヒー |
200 |
6 |
| 6 |
2024-03-10 |
食品 |
みかん |
200 |
4 |
| 7 |
2024-03-20 |
飲料 |
お茶 |
150 |
7 |
| 8 |
2024-04-01 |
日用品 |
石鹸 |
400 |
2 |
| 9 |
2024-04-10 |
食品 |
りんご |
NULL |
1 |
| 10 |
2024-04-15 |
飲料 |
コーヒー |
200 |
5 |
基本:GROUP BY + SUM() で重複データを加算する
最も基本的なパターンです。GROUP BY で同じ値を持つ行をグループ化し、SUM() で数値を合計します。
商品ごとの売上金額合計
SELECT
product,
SUM(amount) AS total_amount,
SUM(quantity) AS total_quantity,
COUNT(*) AS record_count
FROM sales
GROUP BY product
ORDER BY total_amount DESC;
| product |
total_amount |
total_quantity |
record_count |
| りんご |
600 |
9 |
3 |
| コーヒー |
400 |
11 |
2 |
| みかん |
400 |
12 |
2 |
| 石鹸 |
400 |
2 |
1 |
| お茶 |
300 |
17 |
2 |
⚠ 注意:SUM() は NULLを無視して合計を計算します。「りんご」のamountが3行中1行NULLのため、合計は300+300=600になります(NULLの行は加算されません)。NULLを0として扱いたい場合は SUM(COALESCE(amount, 0)) を使います。
関連記事:SUM関数で値の合計を求める方法
SQLの実行順序を理解する
GROUP BY + SUM() を正しく使うには、SQLの実行順序を理解することが重要です。SELECTは見た目上は最初に書きますが、実際の処理順序は異なります。
① FROM(テーブル指定・JOIN)
② WHERE(行の絞り込み)
③ GROUP BY(グループ化)
④ SUM() / COUNT()(集計関数の評価)
⑤ HAVING(集計結果の絞り込み)
⑥ SELECT(出力列の決定)
⑦ ORDER BY(並び替え)
? ポイント:WHEREはGROUP BYの「前」に処理されるため、集計関数(SUM, COUNTなど)は使えません。集計結果に条件を付けるにはGROUP BYの「後」に処理されるHAVINGを使います。この処理順序を覚えておくと、エラーの原因がすぐ分かるようになります。
HAVING句で集計結果を絞り込む
GROUP BYで集計した結果に条件を付けるには、WHEREではなくHAVINGを使います。WHEREはグループ化の「前」、HAVINGはグループ化の「後」に評価されます。
合計金額が400以上の商品のみ
SELECT
product,
SUM(amount) AS total_amount
FROM sales
GROUP BY product
HAVING SUM(amount) >= 400
ORDER BY total_amount DESC;
| 句 |
評価タイミング |
集計関数 |
用途 |
| WHERE |
GROUP BY の前 |
❌ 使えない |
個々の行を絞り込む |
| HAVING |
GROUP BY の後 |
✅ 使える |
グループの集計結果を絞り込む |
関連記事:HAVING句でグループ化されたデータに条件を適用する方法 / GROUP BYで件数をカウント&条件付きで絞り込む方法
複数カラムでグループ化する
GROUP BYに複数のカラムを指定すると、カラムの組み合わせごとにグループ化されます。
カテゴリ × 商品ごとの集計
SELECT
category,
product,
SUM(amount) AS total_amount,
SUM(quantity) AS total_quantity
FROM sales
GROUP BY category, product
ORDER BY category, product;
月ごとの売上集計(日付のグループ化)
月別の売上合計(RDBMS別)
-- MySQL / MariaDB
SELECT
DATE_FORMAT(sale_date, '%Y-%m') AS sale_month,
SUM(amount) AS monthly_total
FROM sales
GROUP BY DATE_FORMAT(sale_date, '%Y-%m');
-- PostgreSQL
SELECT
TO_CHAR(sale_date, 'YYYY-MM') AS sale_month,
SUM(amount) AS monthly_total
FROM sales
GROUP BY TO_CHAR(sale_date, 'YYYY-MM');
-- Oracle
SELECT
TO_CHAR(sale_date, 'YYYY-MM') AS sale_month,
SUM(amount) AS monthly_total
FROM sales
GROUP BY TO_CHAR(sale_date, 'YYYY-MM');
-- SQL Server
SELECT
FORMAT(sale_date, 'yyyy-MM') AS sale_month,
SUM(amount) AS monthly_total
FROM sales
GROUP BY FORMAT(sale_date, 'yyyy-MM');
条件付き集計:SUM(CASE WHEN …)
GROUP BYの中で条件分岐して集計したい場合は、SUM(CASE WHEN … THEN … ELSE … END) を使います。クロス集計表の作成に便利です。
カテゴリ別の月次クロス集計
SELECT
category,
SUM(CASE WHEN MONTH(sale_date) = 1 THEN amount ELSE 0 END) AS jan,
SUM(CASE WHEN MONTH(sale_date) = 2 THEN amount ELSE 0 END) AS feb,
SUM(CASE WHEN MONTH(sale_date) = 3 THEN amount ELSE 0 END) AS mar,
SUM(CASE WHEN MONTH(sale_date) = 4 THEN amount ELSE 0 END) AS apr,
SUM(amount) AS total
FROM sales
GROUP BY category;
? ポイント:CASE式のELSEを省略するとNULLになります。SUM()はNULLを無視するため結果は同じですが、可読性のために ELSE 0 を明示するのがおすすめです。
SUM(DISTINCT):重複する値を除外して合計する
SUM()の中にDISTINCTを指定すると、同じ値を1つとして合計します。
SUM vs SUM(DISTINCT) の違い
-- りんごの amount は 300, 300, NULL
SELECT
product,
SUM(amount) AS sum_all, -- 300+300 = 600
SUM(DISTINCT amount) AS sum_distinct -- 300(重複除外)
FROM sales
WHERE product = 'りんご'
GROUP BY product;
⚠ 注意:SUM(DISTINCT) は「同じ金額が複数回ある場合に1回だけ加算する」ため、意図しない結果になることがあります。「重複レコード」ではなく「重複する値」を除外する点に注意してください。通常の集計には SUM() を使いましょう。
関連記事:DISTINCTで重複したデータを削除する方法
JOINとSUM()の落とし穴:重複増幅問題
複数テーブルをJOINして集計する際、1対多のリレーションがあると行が増幅し、SUM()の結果が想定より大きくなります。これは実務で最もよくある集計バグの一つです。
❌ 間違い:JOINで行が増幅してSUMが膨らむ
-- orders: 注文テーブル(amount=1000 の注文が1件)
-- details: 明細テーブル(その注文に明細が3件)
-- → JOINすると注文が3行に増幅、SUM(amount)=3000 になってしまう!
SELECT
o.customer_id,
SUM(o.amount) AS total_orders, -- ❌ 3000(本当は1000)
COUNT(d.detail_id) AS detail_count
FROM orders o
JOIN order_details d ON o.id = d.order_id
GROUP BY o.customer_id;
✅ 正しい方法1: 事前集計サブクエリ
SELECT
o_sum.customer_id,
o_sum.total_orders,
d_cnt.detail_count
FROM
(SELECT customer_id, SUM(amount) AS total_orders
FROM orders GROUP BY customer_id) o_sum
JOIN
(SELECT o.customer_id, COUNT(*) AS detail_count
FROM orders o JOIN order_details d ON o.id = d.order_id
GROUP BY o.customer_id) d_cnt
ON o_sum.customer_id = d_cnt.customer_id;
⚠ 見分け方:JOINした後のSUM()が想定より大きい場合は、まず SELECT * FROM テーブルA JOIN テーブルB ... で行数を確認しましょう。元テーブルより行数が増えていれば増幅が起きています。JOINの前に個別に集計するのが安全です。
ROLLUP / CUBE で小計・総計を追加する
集計結果に小計行や総計行を自動で追加するには、ROLLUPを使います。
ROLLUP:小計+総計
ROLLUPで小計・総計を追加
SELECT
COALESCE(category, '【総計】') AS category,
SUM(amount) AS total_amount,
COUNT(*) AS record_count
FROM sales
GROUP BY ROLLUP(category)
ORDER BY category;
2階層のROLLUP(カテゴリ→商品)
SELECT
COALESCE(category, '【総計】') AS category,
COALESCE(product, '【小計】') AS product,
SUM(amount) AS total_amount
FROM sales
GROUP BY ROLLUP(category, product)
ORDER BY category, product;
-- 結果例:
-- 食品 | りんご | 600
-- 食品 | みかん | 400
-- 食品 | 【小計】 | 1000 ← カテゴリの小計
-- 飲料 | お茶 | 300
-- 飲料 | コーヒー | 400
-- 飲料 | 【小計】 | 700 ← カテゴリの小計
-- 日用品 | 石鹸 | 400
-- 日用品 | 【小計】 | 400 ← カテゴリの小計
-- 【総計】| 【小計】 | 2100 ← 全体の総計
| 句 |
生成する小計 |
対応RDBMS |
| ROLLUP(A, B) |
A×B集計、Aの小計、総計 |
MySQL 8+, PostgreSQL, Oracle, SQL Server |
| CUBE(A, B) |
A×B集計、Aの小計、Bの小計、総計 |
PostgreSQL, Oracle, SQL Server |
| GROUPING SETS((A,B),(A),()) |
自由にカスタマイズ可能 |
PostgreSQL, Oracle, SQL Server |
⚠ MySQL の注意:MySQL 8.0 以降で ROLLUP が使えます。MySQL 5.7以前では GROUP BY ... WITH ROLLUP 構文になります(CUBE / GROUPING SETSは非対応)。
GROUPING()関数で小計行を正確に判別する
COALESCEでNULLを「【小計】」に変換する方法は、元データにNULLが含まれる場合に区別できない問題があります。GROUPING()関数を使えば、ROLLUPが生成した小計行かどうかを正確に判定できます。
GROUPING()で小計行を判定
SELECT
CASE
WHEN GROUPING(category) = 1 THEN '【総計】'
ELSE COALESCE(category, '(NULL)')
END AS category,
CASE
WHEN GROUPING(product) = 1 THEN '【小計】'
ELSE product
END AS product,
SUM(amount) AS total_amount,
GROUPING(category) AS is_total, -- 1=総計行, 0=通常行
GROUPING(product) AS is_subtotal
FROM sales
GROUP BY ROLLUP(category, product);
| GROUPING()の戻り値 |
意味 |
使い分け |
| 0 |
通常の行(そのカラムでグループ化されている) |
元データの値をそのまま表示 |
| 1 |
ROLLUP/CUBEが生成した小計・総計行 |
「小計」「総計」などのラベルを表示 |
ウィンドウ関数:SUM() OVER (PARTITION BY …)
GROUP BYは行をまとめて1行にしますが、ウィンドウ関数を使えば元の行を残したまま集計値を付加できます。
各行にカテゴリ合計を付加
SELECT
id,
category,
product,
amount,
SUM(amount) OVER (PARTITION BY category) AS category_total
FROM sales;
-- 結果例:
-- 1 | 食品 | りんご | 300 | 1000 ← 食品の合計
-- 2 | 食品 | みかん | 200 | 1000 ← 食品の合計
-- 3 | 食品 | りんご | 300 | 1000 ← 食品の合計
-- 4 | 飲料 | お茶 | 150 | 700 ← 飲料の合計
-- ...
累計を計算する
ORDER BYで累計を計算
SELECT
sale_date,
amount,
SUM(amount) OVER (ORDER BY sale_date) AS running_total
FROM sales
WHERE amount IS NOT NULL;
-- 結果例:
-- 2024-01-15 | 300 | 300 ← 初回
-- 2024-01-20 | 200 | 500 ← 300+200
-- 2024-02-10 | 300 | 800 ← 500+300
-- 2024-02-15 | 150 | 950 ← 800+150
-- ...
| 方式 |
行数 |
用途 |
| GROUP BY + SUM() |
グループ数に集約される |
集計結果のみ必要な場合 |
| SUM() OVER (PARTITION BY) |
元の行数を維持 |
各行に集計値を付加したい場合 |
NULLの扱いと対策
SUM()はNULLを無視しますが、全行がNULLの場合は結果もNULLになります。これを防ぐにはCOALESCEやISNULLを使います。
NULLの対策パターン
-- パターン1: NULLを0として合計に含める
SELECT product, SUM(COALESCE(amount, 0)) AS total
FROM sales GROUP BY product;
-- パターン2: 結果がNULLの場合に0を返す
SELECT product, COALESCE(SUM(amount), 0) AS total
FROM sales GROUP BY product;
-- SQL Server: ISNULLも使える
SELECT product, ISNULL(SUM(amount), 0) AS total
FROM sales GROUP BY product;
他の集計関数との組み合わせ
SUM以外の集計関数と組み合わせて、多角的にデータを分析できます。
複数の集計関数を組み合わせ
SELECT
category,
COUNT(*) AS record_count,
SUM(amount) AS total_amount,
AVG(amount) AS avg_amount,
MAX(amount) AS max_amount,
MIN(amount) AS min_amount,
SUM(amount * quantity) AS total_revenue
FROM sales
GROUP BY category;
関連記事:AVG関数で平均を求める方法 / COUNT関数でレコード数をカウントする方法 / 年齢グループごとの売上金額の合計と平均
GROUP_CONCAT / STRING_AGG:グループ内の値を文字列結合する
集計と一緒に、グループ内の値をカンマ区切りの文字列にまとめたいケースも実務では頻出します。
カテゴリごとの合計+商品名一覧
-- MySQL / MariaDB
SELECT
category,
SUM(amount) AS total_amount,
GROUP_CONCAT(DISTINCT product ORDER BY product) AS products
FROM sales
GROUP BY category;
-- 結果例:
-- 食品 | 1000 | みかん,りんご
-- 飲料 | 700 | お茶,コーヒー
-- 日用品 | 400 | 石鹸
-- PostgreSQL / SQL Server 2017+
SELECT
category,
SUM(amount) AS total_amount,
STRING_AGG(DISTINCT product, ', ' ORDER BY product) AS products
FROM sales
GROUP BY category;
| RDBMS |
関数 |
区切り文字デフォルト |
| MySQL / MariaDB |
GROUP_CONCAT() |
カンマ(,) |
| PostgreSQL |
STRING_AGG() |
指定必須 |
| SQL Server 2017+ |
STRING_AGG() |
指定必須 |
| Oracle |
LISTAGG() |
指定必須 |
パフォーマンスの注意点
| 対策 |
効果 |
説明 |
| WHERE で先に絞り込む |
◎ 効果大 |
GROUP BYの対象行を減らすと大幅に高速化 |
| GROUP BY列にインデックス |
◎ 効果大 |
ソート処理・一時テーブル作成を回避 |
| 不要なカラムをSELECTしない |
○ 中程度 |
データ転送量の削減 |
| EXPLAIN で実行計画を確認 |
— 診断 |
一時テーブルやファイルソートの使用を確認 |
パフォーマンス改善例
-- ❌ 遅い: 全件をGROUP BYしてからHAVINGで絞る
SELECT category, SUM(amount)
FROM sales
GROUP BY category
HAVING category = '食品';
-- ✅ 速い: WHEREで先に絞ってからGROUP BY
SELECT category, SUM(amount)
FROM sales
WHERE category = '食品'
GROUP BY category;
トラブルシューティング
| エラー・症状 |
原因 |
対処法 |
| Column must appear in GROUP BY clause |
SELECT句に集計関数でもGROUP BY列でもないカラムがある |
そのカラムをGROUP BYに追加するか、集計関数で囲む |
| SUM() の結果がNULL |
対象の全行がNULL |
COALESCE(SUM(col), 0) で0に置換 |
| 合計値が想定より大きい |
JOINで行が重複増幅している |
JOINの結果をSELECTして行数を確認。必要ならSUM(DISTINCT)や事前集計サブクエリを使う |
| HAVINGでWHEREのように絞れない |
HAVINGには集計関数を含む条件を書く |
個々の行の絞り込みはWHERE、集計結果の絞り込みはHAVING |
| GROUP BY の処理が遅い |
インデックスがない・対象行が多すぎる |
GROUP BY列にインデックスを作成。WHEREで先に絞り込む |
どの集計方法を使うべきか?選び方ガイド
集計の要件に応じて最適な方法は異なります。以下のフローで判断できます。
Q1. 元の行を残す必要がある?
→ はい:SUM() OVER (PARTITION BY ...)(ウィンドウ関数)
→ いいえ:Q2 へ
Q2. 累計(ランニングトータル)が必要?
→ はい:SUM() OVER (ORDER BY ...)
→ いいえ:Q3 へ
Q3. 小計・総計の行が必要?
→ はい:GROUP BY ROLLUP(...) または CUBE(...)
→ いいえ:Q4 へ
Q4. 条件ごとに別々の列で集計したい(クロス集計)?
→ はい:SUM(CASE WHEN ... THEN ... END)
→ いいえ:Q5 へ
Q5. 集計結果を絞り込む?
→ はい:GROUP BY ... HAVING SUM(...) >= 値
→ いいえ:基本の GROUP BY + SUM()
まとめ
| やりたいこと |
方法 |
| 重複データを加算する |
GROUP BY + SUM() |
| 集計結果を絞り込む |
HAVING SUM(col) >= 値 |
| 条件分岐して集計する |
SUM(CASE WHEN ... THEN ... END) |
| 小計・総計を追加する |
ROLLUP / CUBE / GROUPING SETS |
| 行を残して集計値を付加する |
SUM() OVER (PARTITION BY ...) |
| 累計を計算する |
SUM() OVER (ORDER BY ...) |
| NULLがある場合の対策 |
COALESCE(SUM(col), 0) |
基本は GROUP BY + SUM() の組み合わせです。この基本をマスターしたうえで、HAVING・ROLLUP・ウィンドウ関数・SUM(CASE)などを組み合わせれば、あらゆる集計要件に対応できます。
関連記事:SUM関数で値の合計を求める方法 / HAVING句でグループ化データに条件を適用する方法 / GROUP BYで件数をカウント&条件付き絞り込み / DISTINCTで重複データを削除する方法