- SQL
SQL GROUP BY HAVING 使い方|集計関数一覧とDB比較・実行結果つき
GROUP BYとは、同じ値を持つ行を1つのグループにまとめ、グループごとに件数・合計・平均などを集計するための句です。HAVINGは、集計した結果に条件をかけてグループを絞り込む句です。 「会員ごとの注文合計」「合計が一定額以上の会員だけ」といった集計は、この2つで書けます。
この記事では、集計関数の一覧表をMySQL・PostgreSQL・Oracle・SQL Serverの対応つきで整理し、よく使う集計をコピペできるSQLで紹介します。PostgreSQL 16.13とMySQL 8.0.46で実際に実行し、エラー文を含めて出力をそのまま掲載しています(OracleとSQL Serverは公式ドキュメント準拠)。
GROUP BY・HAVINGと集計関数の早見表
| やりたいこと | MySQL 8.0(実行確認済み) | PostgreSQL 16(実行確認済み) | Oracle(ドキュメント準拠) | SQL Server(ドキュメント準拠) | NULLの扱い |
|---|---|---|---|---|---|
| 行数 | COUNT(*) |
同左 | 同左 | 同左 | NULLの行も数える |
| 値がある行の数 | COUNT(列) |
同左 | 同左 | 同左 | 数えない |
| 種類の数 | COUNT(DISTINCT 列) |
同左 | 同左 | 同左 | 数えない |
| 合計 | SUM(列) |
同左 | 同左 | 同左 | 無視。対象0行ならNULL |
| 平均 | AVG(列) |
同左 | 同左 | 同左 | 分母に入らない |
| 最小・最大 | MIN / MAX |
同左 | 同左 | 同左 | 無視 |
| 文字列を連結 | GROUP_CONCAT(列 ORDER BY … SEPARATOR ',') |
STRING_AGG(列, ',' ORDER BY …) |
LISTAGG(列, ',') WITHIN GROUP (ORDER BY …) |
STRING_AGG(列, ',') WITHIN GROUP (ORDER BY …) |
飛ばす |
| 配列・JSON | JSON_ARRAYAGG(列) |
ARRAY_AGG(列) / JSON_AGG(列) |
JSON_ARRAYAGG(列) |
JSON_ARRAYAGG(列)(2025以降) |
MySQL・PGでは要素に残る |
| すべて真/1つでも真 | MIN(列) / MAX(列)(0/1) |
BOOL_AND / BOOL_OR |
MIN / MAXで代用 |
MIN / MAXで代用 |
無視 |
| ビット演算 | BIT_AND / BIT_OR / BIT_XOR |
同左 | BIT_AND_AGG など(21c以降) |
集計版なし | 無視 |
| 標準偏差・分散 | STDDEV_POP / STDDEV_SAMP / VAR_POP |
同左 | 同左 | STDEVP / STDEV / VARP |
無視 |
| 中央値 | なし(ウィンドウ関数で計算) | PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY 列) |
MEDIAN(列) |
PERCENTILE_CONT(ウィンドウ関数) |
無視 |
| 最頻値 | なし | MODE() WITHIN GROUP (ORDER BY 列) |
STATS_MODE(列) |
なし | 無視 |
| どれか1つの値 | ANY_VALUE(列) |
ANY_VALUE(列)(16以降) |
ANY_VALUE(列) |
なし | ― |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQL GROUP BY HAVING 使い方|集計関数一覧とDB比較・実行結果つき」
サンプルデータ(コピペ用)
NULLを含む行と、注文0件の会員(江藤)を入れたデータです。両DBでそのまま動きます。
CREATE TABLE members (id INT PRIMARY KEY, name VARCHAR(20));
INSERT INTO members VALUES (1,'青木'),(2,'井上'),(3,'上田'),(4,'江藤');
CREATE TABLE orders (
id INT PRIMARY KEY, member_id INT, category VARCHAR(10),
amount INT, shipped BOOLEAN, ordered_on DATE
);
INSERT INTO orders VALUES
(1,1,'本',1200,TRUE,'2026-08-03'), (2,1,'本',800,TRUE,'2026-08-10'),
(3,1,'文具',300,FALSE,'2026-09-01'), (4,2,'本',1500,TRUE,'2026-08-21'),
(5,2,'家電',9800,TRUE,'2026-09-05'), (6,3,'文具',NULL,FALSE,'2026-09-07'),
(7,3,NULL,500,TRUE,'2026-09-12'), (8,3,NULL,700,FALSE,'2026-09-15');
実行環境は PostgreSQL 16.13とMySQL 8.0.46(sql_mode既定値) です。MySQL 8.0は2026年4月にサポートを終えていますが、この記事の構文は長期サポート版の8.4・9.7でも同じです。
GROUP BYの基本とNULLのグループ
GROUP BY 列と書くと、その列の値が同じ行が1行にまとまり、集計関数はグループごとに計算されます。
SELECT category, COUNT(*) AS cnt, SUM(amount) AS total
FROM orders GROUP BY category ORDER BY category;
PostgreSQL 16(MySQL 8.0は同じ4行で、NULLの行が先頭):
category | cnt | total
----------+-----+-------
家電 | 1 | 9800
文具 | 2 | 300
本 | 3 | 3500
| 2 | 1200
- NULLは「NULLのグループ」として1つにまとまります。 WHEREの
=ではNULL同士は一致しませんが、GROUP BYでは同じグループです。 - NULLグループの並び順はDBで違います(昇順でPostgreSQLは最後、MySQLは最初)。
- 「未設定」と表示したいときは
GROUP BY COALESCE(category, '未設定')でまとめます。両DBで「未設定 | 2」の行になりました。
集計関数の実行結果とNULLの扱い
COUNT(*)・COUNT(列)・COUNT(DISTINCT)の違い
SELECT COUNT(*) AS cnt_all, COUNT(amount) AS cnt_amount,
COUNT(category) AS cnt_cat, COUNT(DISTINCT category) AS cnt_cat_distinct
FROM orders;
実行結果(両DBで同じ値):
cnt_all | cnt_amount | cnt_cat | cnt_cat_distinct
---------+------------+---------+------------------
8 | 7 | 6 | 3
COUNT(*)は行数、COUNT(列)はNULLでない行数、COUNT(DISTINCT 列)はNULLを除いた種類数です。
SUM・AVG・MIN・MAXと、0行のとき
全体でSUM(amount)は14800、MINは300、MAXは9800、AVGはPostgreSQLで2114.2857142857142857、MySQLで2114.2857でした。AVGはNULLの行を分母に入れません(14800 ÷ 7)。NULLを0として扱うならAVG(COALESCE(amount, 0))です(「文具」で試すとAVG(amount)は300、AVG(COALESCE(amount, 0))は150)。
対象が0行のとき、SUM・AVGはNULL、COUNTは0を返します。 WHERE amount > 100000で試すと、両DBともSUMとAVGがNULL、COUNT(*)が0でした。数値として扱うならCOALESCE(SUM(amount), 0)としておきます。
文字列の連結:STRING_AGGとGROUP_CONCAT
-- PostgreSQL
SELECT member_id, STRING_AGG(category, ',' ORDER BY id) AS cats,
BOOL_AND(shipped) AS all_shipped, BOOL_OR(shipped) AS any_shipped
FROM orders GROUP BY member_id ORDER BY member_id;
-- MySQL(BOOLEANは0/1なのでMIN/MAXで代用)
SELECT member_id, GROUP_CONCAT(category ORDER BY id SEPARATOR ',') AS cats,
MIN(shipped) AS all_shipped, MAX(shipped) AS any_shipped
FROM orders GROUP BY member_id ORDER BY member_id;
PostgreSQL 16(MySQLはf/tが0/1になるだけで同じ内容):
member_id | cats | all_shipped | any_shipped
-----------+------------+-------------+-------------
1 | 本,本,文具 | f | t
2 | 本,家電 | t | t
3 | 文具 | f | t
会員3のカテゴリは「文具・NULL・NULL」ですが、連結ではNULLが飛ばされます。一方、PostgreSQLのARRAY_AGG(amount)は{NULL,500,700}、MySQLのJSON_ARRAYAGG(amount)は[null, 500, 700]となり、配列・JSON系ではNULLも要素に残りました。
GROUP_CONCATには長さの上限(group_concat_max_len、既定1024バイト)があります。 上限を10にして試すと、MySQL 8.0で結果が本,本,で切れ、Warning 1260: Row 3 was cut by GROUP_CONCAT()が出ました。長い一覧はセッションで上限を上げてから連結します。
統計系(標準偏差・中央値・最頻値)
-- PostgreSQL
SELECT ROUND(STDDEV_POP(amount), 1) AS sd_pop,
ROUND(STDDEV_SAMP(amount), 1) AS sd_samp,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median,
MODE() WITHIN GROUP (ORDER BY category) AS mode_cat
FROM orders;
sd_pop | sd_samp | median | mode_cat
--------+---------+--------+----------
3160.2 | 3413.4 | 800 | 本
MySQL 8.0でもSTDDEV_POP・STDDEV_SAMPは同じ値でした。中央値・最頻値の集計関数はMySQLにないため、MySQLではウィンドウ関数で順位をつけて計算します。
WHERE・GROUP BY・HAVINGの評価順
SQLは「FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY」の順に評価されます(詳しくは「SQLの実行順序」の記事)。
flowchart LR
A[FROM] --> B[WHERE<br/>行を絞る] --> C[GROUP BY<br/>まとめる] --> D[HAVING<br/>グループを絞る] --> E[SELECT<br/>出力] --> F[ORDER BY]
WHEREの時点ではまだグループがないため集計関数は使えず、HAVINGはGROUP BYの後なので使えます。 ORDER BYはSELECTの後なので別名が使えます。
HAVINGとWHEREの使い分け
直す前:WHEREに集計関数を書くとエラー
SELECT member_id, SUM(amount) AS total
FROM orders WHERE SUM(amount) >= 2000 GROUP BY member_id;
実行結果(上がPostgreSQL 16、下がMySQL 8.0):
ERROR: aggregate functions are not allowed in WHERE
ERROR 1111 (HY000) at line 8: Invalid use of group function
直した後:集計値の条件はHAVINGへ
SELECT member_id, SUM(amount) AS total
FROM orders GROUP BY member_id
HAVING SUM(amount) >= 2000
ORDER BY member_id;
実行結果(両DBで同じ):
member_id | total
-----------+-------
1 | 2300
2 | 11300
HAVINGでSELECTの別名は使える?(DBで違う)
HAVING total >= 2000と別名で書くと、MySQL 8.0は成功し、PostgreSQL 16はエラーになりました。
ERROR: column "total" does not exist
LINE 1: ...t) AS total FROM orders GROUP BY member_id HAVING total >= 2...
どのDBでも動かすなら、HAVINGにはSUM(amount)のように式をそのまま書きます。
使い分けの判断
| 条件の中身 | 書く場所 | 例 |
|---|---|---|
| 1行ごとの列の値 | WHERE | WHERE shipped、WHERE ordered_on >= '2026-09-01' |
| 集計した値 | HAVING | HAVING COUNT(*) >= 2、HAVING SUM(amount) >= 2000 |
| 両方 | WHEREで絞ってからHAVING | 下の例 |
1行ごとの列の値(shipped、日付など)はWHERE、集計した値(件数・合計)はHAVINGに書きます。行の条件をWHEREに書くと、集計する行が先に減るため効率的です。「発送済みの注文が2件以上ある会員」ならWHERE shipped GROUP BY member_id HAVING COUNT(*) >= 2で、会員1・2(各2件)が返りました。
GROUP BYにない列をSELECTするとエラーになる
エラーの再現
1行にまとめた後、グループ内で値が複数ありうる列は、どれを出すか決められません。 そのためSELECTに書けるのは「GROUP BYの列」「集計関数」「定数」です。
SELECT member_id, category, SUM(amount) FROM orders GROUP BY member_id;
PostgreSQL 16:
ERROR: column "orders.category" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: SELECT member_id, category, SUM(amount) FROM orders GROUP BY...
^
MySQL 8.0(sql_mode既定値にONLY_FULL_GROUP_BYを含む):
ERROR 1055 (42000) at line 5: Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 't38.orders.category' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
直し方は3通り
| 目的 | 直し方 |
|---|---|
| カテゴリ別にも分けたい | GROUP BY member_id, category |
| 代表値が1つあればよい | MIN(category) / MAX(category) / ANY_VALUE(category) |
| 一覧で見たい | STRING_AGG / GROUP_CONCAT |
主キーでGROUP BYしていれば、同じテーブルの他の列は書けます。 SELECT m.id, m.name, SUM(o.amount) FROM members m JOIN orders o ON o.member_id = m.id GROUP BY m.idは、両DBでエラーにならず会員名つきで集計できました。
MySQLでONLY_FULL_GROUP_BYを外すと上のSQLも通り、会員1(本・文具)のカテゴリは「本」になりました。どの値が返るかは保証されないため、sql_modeは既定値のまま、SQLを上の3通りで直すと結果が安定します。
小計・総計:ROLLUP・CUBE・GROUPING SETS
ROLLUP
ROLLUPは、右の列から順にまとめた小計と総計の行を追加します。
-- PostgreSQL(MySQLは GROUP BY member_id, category WITH ROLLUP と書く)
SELECT member_id, category, SUM(amount) AS total, GROUPING(member_id, category) AS g
FROM orders WHERE member_id IN (1, 2)
GROUP BY ROLLUP (member_id, category)
ORDER BY member_id, category;
PostgreSQL 16(MySQL 8.0も同じ行。空欄はNULL):
member_id | category | total | g
-----------+----------+-------+---
1 | 文具 | 300 | 0
1 | 本 | 2000 | 0
1 | | 2300 | 1
2 | 家電 | 9800 | 0
2 | 本 | 1500 | 0
2 | | 11300 | 1
| | 13600 | 3
GROUPING()はまとめられた列なら1を返し、2列を渡すとビットで返ります(1=会員の小計、3=総計)。元データのNULLと小計のNULLを区別でき、MySQLならIF(GROUPING(member_id), '合計', member_id)、PostgreSQLならCASE WHEN GROUPING(member_id) = 1 THEN '合計' ELSE member_id::text ENDで、総計行に「合計」と表示できました。
CUBEとGROUPING SETS
GROUPING SETSは欲しい集計の組み合わせを指定し、CUBEはすべての組み合わせを作ります。
-- PostgreSQL:会員別・カテゴリ別・全体を1回で
SELECT member_id, category, COUNT(*)
FROM orders
GROUP BY GROUPING SETS ((member_id), (category), ())
ORDER BY member_id NULLS LAST, category NULLS LAST;
PostgreSQL 16では、会員別3行(3・2・3件)、カテゴリ別4行(家電1・文具2・本3・NULL 2件)、全体1行(8件)の計8行が1回で返りました。MySQL 8.0ではCUBEもGROUPING SETSもERROR 1064 (42000) … syntaxになりました。
| 機能 | MySQL 8.0 | PostgreSQL 16 | Oracle / SQL Server(ドキュメント準拠) |
|---|---|---|---|
| ROLLUP | WITH ROLLUP |
ROLLUP (…) |
ROLLUP (…) |
| CUBE | なし(UNION ALLで代用) |
CUBE (…) |
CUBE (…) |
| GROUPING SETS | なし(UNION ALLで代用) |
GROUPING SETS (…) |
GROUPING SETS (…) |
| GROUPING() | あり(8.0.1以降) | あり | あり |
条件つき集計:FILTER句とCASE式
「全件数」と「発送済み件数」を1回で並べるには、PostgreSQLはFILTER、MySQLはCASE式を使います。
-- PostgreSQL
SELECT member_id, COUNT(*) AS cnt,
COUNT(*) FILTER (WHERE shipped) AS shipped_cnt,
SUM(amount) FILTER (WHERE category = '本') AS book_total
FROM orders GROUP BY member_id ORDER BY member_id;
-- MySQL
SELECT member_id, COUNT(*) AS cnt,
SUM(shipped) AS shipped_cnt,
SUM(CASE WHEN category = '本' THEN amount END) AS book_total
FROM orders GROUP BY member_id ORDER BY member_id;
PostgreSQL 16(MySQL 8.0も同じ値。空欄はNULL):
member_id | cnt | shipped_cnt | book_total
-----------+-----+-------------+------------
1 | 3 | 2 | 2000
2 | 2 | 2 | 1500
3 | 3 | 1 |
CASE式はどのDBでも動く書き方です。
0件のグループを出す:LEFT JOINとCOUNT
直す前:COUNT(*)だと0件が1になる
SELECT m.id, m.name,
COUNT(*) AS wrong_cnt, COUNT(o.id) AS right_cnt,
COALESCE(SUM(o.amount), 0) AS total
FROM members m LEFT JOIN orders o ON o.member_id = m.id
GROUP BY m.id, m.name ORDER BY m.id;
PostgreSQL 16:
id | name | wrong_cnt | right_cnt | total
----+------+-----------+-----------+-------
1 | 青木 | 3 | 3 | 2300
2 | 井上 | 2 | 2 | 11300
3 | 上田 | 3 | 3 | 1200
4 | 江藤 | 1 | 0 | 0
注文のない江藤さんはLEFT JOINで「注文側がすべてNULLの1行」になります。COUNT(*)はその1行を数えて1、COUNT(o.id)はNULLを数えないので0です。
直した後:子テーブルの条件はONに書く
発送済みの注文だけを数えようとして… LEFT JOIN orders o ON o.member_id = m.id WHERE o.shippedと書くと、結果は会員1〜3の3行だけになり、江藤さんが消えました。WHEREは結合後の行を絞るため、NULLの行も落ちます。 条件をONに書くと残ります。
SELECT m.id, COUNT(o.id) AS cnt
FROM members m LEFT JOIN orders o ON o.member_id = m.id AND o.shipped
GROUP BY m.id ORDER BY m.id;
id | cnt
----+-----
1 | 2
2 | 2
3 | 1
4 | 0
0件の会員(id 4)が残りました。
グループごとの上位N件
明細を残してグループごとに絞るのはウィンドウ関数の役割です。会員ごとに金額の大きい注文を2件ずつ取り出します。
SELECT member_id, id, amount
FROM (
SELECT member_id, id, amount,
ROW_NUMBER() OVER (PARTITION BY member_id ORDER BY amount DESC, id) AS rn
FROM orders
) t
WHERE rn <= 2
ORDER BY member_id, rn;
MySQL 8.0:
+-----------+----+--------+
| member_id | id | amount |
+-----------+----+--------+
| 1 | 1 | 1200 |
| 1 | 2 | 800 |
| 2 | 5 | 9800 |
| 2 | 4 | 1500 |
| 3 | 8 | 700 |
| 3 | 7 | 500 |
+-----------+----+--------+
PostgreSQLは降順でNULLが先頭に来るため、ORDER BY amount DESC NULLS LAST, idとしたところ同じ結果になりました。ウィンドウ関数の詳しい使い方は「SQLウィンドウ関数」の記事で扱っています。
よくある誤りと直し方
| 症状 | 原因 | 直し方 |
|---|---|---|
must appear in the GROUP BY clause / ERROR 1055 |
集計していない列をSELECT | GROUP BYに追加、またはMIN・ANY_VALUE |
aggregate functions are not allowed in WHERE / ERROR 1111 |
WHEREに集計関数 | HAVINGへ |
| 0件のグループが出ない/0件が1になる | WHEREで子を絞った/COUNT(*) |
条件をONへ/COUNT(子.id) |
| 件数が倍になる | JOINで親の行が増えた | 子を先に集計してJOIN |
| 平均が想定より大きい | AVGはNULLを分母に入れない | AVG(COALESCE(列, 0)) |
| 合計がNULL | 対象行が0 | COALESCE(SUM(列), 0) |
| 連結文字列が途中で切れる | group_concat_max_len |
セッションで上限を上げる |
MySQLでCUBEが構文エラー |
MySQLは未対応 | WITH ROLLUPかUNION ALL |
よくある質問
Q. GROUP BYとDISTINCTはどう違いますか? A. 重複を除くだけなら結果は同じですが、件数や合計も出すならGROUP BYです。
Q. GROUP BYなしでHAVINGは書けますか?
A. 書けます。テーブル全体が1グループになります。SELECT COUNT(*) FROM orders HAVING COUNT(*) > 5は両DBで8を1行返し、> 10にすると0行でした。
Q. COUNT(DISTINCT)で複数列を数えるには?
A. MySQLはCOUNT(DISTINCT member_id, category)で5、PostgreSQLはCOUNT(DISTINCT (member_id, category))で6でした。MySQLはNULLを含む組み合わせを数えず、PostgreSQLは数えるためです。NULLを含む列ではWHERE category IS NOT NULLで条件をそろえると確実です。
まとめ
- GROUP BYは同じ値の行をまとめ、集計関数でグループごとに計算する。NULLも1つのグループになる
- COUNT(*)は行数、COUNT(列)はNULL以外の数。SUM・AVGはNULLを無視し、対象0行ならNULL
- 行の条件はWHERE、集計値の条件はHAVING。HAVINGの別名はMySQLだけ使える
- SELECTに書ける列はGROUP BYの列・集計関数・主キーに従属する列。
ONLY_FULL_GROUP_BYは既定値のままにする - 小計・総計はROLLUP(MySQLは
WITH ROLLUP)、判定はGROUPING() - 条件つき集計はFILTER(PG)かCASE式(共通)
- 0件のグループはLEFT JOIN+COUNT(子.id)、子の条件はONに書く
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQL GROUP BY HAVING 使い方|集計関数一覧とDB比較・実行結果つき」
株式会社RJCでは、集計クエリの書き方やDBごとの違いを、コードレビューの中で仲間と確かめ合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。