SQLで「カテゴリごとの注文数が5件以上の商品を出す」「同じメールアドレスが複数登録されているレコードを探す」「月ごとにアクティブユーザー数を集計する」といった集計+絞り込みは、データ分析・品質チェック・レポート作成のどの場面でも頻繁に使います。
本記事では GROUP BY + COUNT + HAVING の組み合わせを中心に、条件付きCOUNT・複合グループ化・JOIN後の集計・ウィンドウ関数との使い分け・パフォーマンスまで、現場で役立つパターンをすべて解説します。
- GROUP BY + COUNT + HAVING の基本構文とSQL実行順序
- WHERE(集計前フィルター)と HAVING(集計後フィルター)の完全な使い分け
- COUNT(CASE WHEN) で条件ごとの件数を1クエリで取得する方法
- 複数カラムの GROUP BY と多次元集計(ROLLUP)
- JOIN と GROUP BY を組み合わせた集計パターン
- HAVING の複合条件(AND / OR / サブクエリ)
- GROUP BY のパフォーマンスとインデックス設計
基本構文と SQL の実行順序
GROUP BY を使ったクエリは次の順序で処理されます。この順序を理解することが、WHERE・HAVING の使い分けを理解する鍵になります。
| 順序 | 句 | 処理内容 |
|---|---|---|
| ① | FROM / JOIN | 対象テーブルを結合して全行を読み込む |
| ② | WHERE | 行単位で条件フィルタ(集計前) |
| ③ | GROUP BY | 指定カラムでグループ化 |
| ④ | 集計関数 | COUNT / SUM / AVG などをグループ単位で計算 |
| ⑤ | HAVING | 集計結果に対して条件フィルタ(集計後) |
| ⑥ | SELECT | 出力カラムを選択 |
| ⑦ | ORDER BY | 結果を並び替え |
| ⑧ | LIMIT / FETCH | 件数制限 |
SELECT
グループ化カラム,
COUNT(*) AS 件数,
COUNT(カラム名) AS NULL除外件数
FROM テーブル名
WHERE 行フィルター条件 -- ③GROUP BY の前に適用
GROUP BY グループ化カラム
HAVING COUNT(*) >= 閾値 -- ⑤集計後に適用
ORDER BY 件数 DESC;
SELECT
category,
COUNT(*) AS product_count
FROM products
WHERE is_active = 1 -- 販売中の商品のみ集計
GROUP BY category
HAVING COUNT(*) >= 5 -- 5件以上のカテゴリだけ表示
ORDER BY product_count DESC;
WHERE と HAVING の使い分け
最も混乱しやすいポイントです。一言で言うと「GROUP BY の前か後か」で使い分けます。
| 項目 | WHERE | HAVING |
|---|---|---|
| 適用タイミング | GROUP BY の前 | GROUP BY の後 |
| 対象 | 個々の行 | グループ化後の集計結果 |
| 集計関数 | 使用不可 | 使用可能 |
| インデックス | 使える | 使えない(集計後のため) |
| 代表的な用途 | 期間絞り込み・フラグフィルター | 件数・合計・平均の閾値フィルター |
-- NG: WHERE に集計関数は書けない SELECT user_id, COUNT(*) FROM orders WHERE COUNT(*) >= 3 -- エラー: WHEREに集計関数は不可 GROUP BY user_id; -- OK: 集計前の条件は WHERE, 集計後の条件は HAVING SELECT user_id, COUNT(*) AS order_count FROM orders WHERE status = 'confirmed' -- 確定済み注文だけで集計 GROUP BY user_id HAVING COUNT(*) >= 3; -- 3件以上のユーザーのみ表示
WHERE は集計前に行を絞り込むため、処理対象行数が減ってパフォーマンスが向上します。HAVING は全グループを集計してから絞るため、行数が多いほどコストが高くなります。「集計前に条件を適用できるか?」を常に意識して、できる限り WHERE に書くようにしましょう。
COUNT(*) と COUNT(カラム名) の違い
GROUP BY と組み合わせる際に COUNT の動作の違いが重要になります。
-- orders テーブル(shipped_at が NULL = 未発送)
-- | user_id | shipped_at |
-- | 1 | 2024-01-10 |
-- | 1 | NULL |
-- | 2 | 2024-01-12 |
SELECT
user_id,
COUNT(*) AS total_orders, -- NULL含む全件: 1→2, 2→1
COUNT(shipped_at) AS shipped_orders -- NULL除外: 1→1, 2→1
FROM orders
GROUP BY user_id;
COUNT(*):グループ内の全行数(NULL を含む)COUNT(カラム名):そのカラムが非NULL の行数のみCOUNT(DISTINCT カラム名):重複を除いたユニーク件数COUNT の詳細な動作については COUNT関数完全ガイド も参照してください。
条件付き COUNT:グループ内で条件ごとの件数を出す
「1つのクエリで「完了件数」と「未完了件数」を同時に出したい」という場合、COUNT(CASE WHEN) または SUM(CASE WHEN) を使います。
-- 注文ステータス別の件数を1クエリで取得
SELECT
user_id,
COUNT(*) AS total,
COUNT(CASE WHEN status = 'confirmed' THEN 1 END) AS confirmed,
COUNT(CASE WHEN status = 'shipped' THEN 1 END) AS shipped,
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) AS cancelled
FROM orders
GROUP BY user_id
ORDER BY total DESC;
-- COUNT(CASE WHEN ... THEN 1 END) は NULL を返した行を除外してカウント
-- SUM(CASE WHEN ... THEN 1 ELSE 0 END) も同じ結果になる
-- ただし ELSE 0 を書いた場合: SUM は 0 を加算するため必ず同じ
SELECT
department,
COUNT(CASE WHEN score >= 80 THEN 1 END) AS high_score_count,
SUM(CASE WHEN score >= 80 THEN 1 ELSE 0 END) AS high_score_sum,
-- 構成比も同時に計算
ROUND(
100.0 * COUNT(CASE WHEN score >= 80 THEN 1 END) / COUNT(*), 1
) AS high_score_rate
FROM employees
GROUP BY department;
-- 「キャンセル率が20%以上のユーザー」を抽出
SELECT
user_id,
COUNT(*) AS total,
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) AS cancelled
FROM orders
GROUP BY user_id
HAVING
COUNT(*) >= 3 -- 3件以上発注したことがある
AND COUNT(CASE WHEN status = 'cancelled' THEN 1 END) * 100
/ COUNT(*) >= 20; -- キャンセル率20%以上
複数カラムの GROUP BY
GROUP BY には複数カラムを指定できます。「年・月ごと」「カテゴリ×ステータスごと」のような多次元集計が可能です。
-- 年月 × カテゴリ の件数集計
SELECT
DATE_FORMAT(order_date, '%Y-%m') AS year_month,
category,
COUNT(*) AS order_count
FROM orders
JOIN products USING (product_id)
GROUP BY DATE_FORMAT(order_date, '%Y-%m'), category
ORDER BY year_month, category;
-- PostgreSQL: DATE_TRUNC でグループ化
SELECT
DATE_TRUNC('month', order_date) AS year_month,
category,
COUNT(*) AS order_count
FROM orders
JOIN products USING (product_id)
GROUP BY DATE_TRUNC('month', order_date), category
ORDER BY year_month, category;
-- Oracle: TRUNC でグループ化
SELECT
TRUNC(order_date, 'MM') AS year_month,
category,
COUNT(*) AS order_count
FROM orders
JOIN products USING (product_id)
GROUP BY TRUNC(order_date, 'MM'), category
ORDER BY year_month, category;
GROUP BY + ROLLUP で小計・合計を同時に出す
ROLLUP を使うと、グループ別集計に加えて小計・合計行を自動で追加できます。
-- MySQL / PostgreSQL / Oracle / SQL Server 全対応
SELECT
COALESCE(category, '【全カテゴリ合計】') AS category,
COALESCE(status, '【ステータス小計】') AS status,
COUNT(*) AS cnt
FROM orders
JOIN products USING (product_id)
GROUP BY ROLLUP (category, status)
ORDER BY category, status;
-- MySQL 互換構文 (WITH ROLLUP) ← MySQL の全バージョンで動作
SELECT category, status, COUNT(*) AS cnt
FROM orders
JOIN products USING (product_id)
GROUP BY category, status WITH ROLLUP;
JOIN と GROUP BY の組み合わせ
別テーブルとJOINしてから GROUP BY するパターンは実務で頻繁に登場します。特に「マスターテーブルを保持しつつ0件のグループも出す」場合は LEFT JOIN が必須です。
-- 顧客ごとの注文件数と合計金額
SELECT
c.customer_id,
c.name,
COUNT(o.order_id) AS order_count,
SUM(o.amount) AS total_amount
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.name
HAVING COUNT(o.order_id) >= 2
ORDER BY total_amount DESC;
-- 注文のない顧客も含め、顧客全員の注文件数を集計
SELECT
c.customer_id,
c.name,
COUNT(o.order_id) AS order_count -- NULLは除外されるため0になる
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.name
ORDER BY order_count DESC;
-- 注文0件の顧客だけ抽出
SELECT c.customer_id, c.name
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.name
HAVING COUNT(o.order_id) = 0;
LEFT JOIN 後に
COUNT(*) を使うと、右テーブルが対応なし(NULL)でも1件としてカウントされます。注文のない顧客を「0件」と表示したい場合は COUNT(o.order_id) のようにNULLになるカラムを指定してください。HAVING の複合条件(AND / OR / サブクエリ)
HAVING には複数の条件を組み合わせたり、サブクエリを使ったりできます。
-- 「注文件数3件以上 かつ 合計金額1万円以上」のユーザー
SELECT user_id, COUNT(*) AS cnt, SUM(amount) AS total
FROM orders
GROUP BY user_id
HAVING COUNT(*) >= 3
AND SUM(amount) >= 10000;
-- 「5件以上 または 合計5万円以上」の VIPユーザー候補
SELECT user_id, COUNT(*) AS cnt, SUM(amount) AS total
FROM orders
GROUP BY user_id
HAVING COUNT(*) >= 5
OR SUM(amount) >= 50000;
-- 全体平均件数より多い注文数を持つユーザー
SELECT user_id, COUNT(*) AS order_count
FROM orders
GROUP BY user_id
HAVING COUNT(*) > (
SELECT AVG(cnt)
FROM (
SELECT COUNT(*) AS cnt
FROM orders
GROUP BY user_id
) t
);
-- CTE を使った読みやすい書き方(MySQL 8.0+ / PostgreSQL / Oracle / SS)
WITH user_counts AS (
SELECT user_id, COUNT(*) AS order_count
FROM orders
GROUP BY user_id
),
avg_count AS (
SELECT AVG(order_count) AS avg_cnt
FROM user_counts
)
SELECT uc.user_id, uc.order_count
FROM user_counts uc
CROSS JOIN avg_count ac
WHERE uc.order_count > ac.avg_cnt
ORDER BY uc.order_count DESC;
GROUP BY とウィンドウ関数の使い分け
GROUP BY は行をまとめて1行に集約しますが、ウィンドウ関数(OVER())は元の行を保持したまま集計値を付与します。要件によって使い分けましょう。
| 観点 | GROUP BY | ウィンドウ関数(OVER) |
|---|---|---|
| 出力行数 | グループ数と同じ(行が集約される) | 元のテーブルと同じ行数 |
| 元の行データ | 失われる | 保持される |
| 用途 | グループ単位の集計レポート | 各行に集計値・順位を付与 |
| HAVING | 使える | 使えない(CTEかサブクエリが必要) |
-- GROUP BY: ユーザー別の合計注文件数(行が集約される)
SELECT user_id, COUNT(*) AS order_count
FROM orders
GROUP BY user_id;
-- ウィンドウ関数: 各注文行にユーザーの合計注文件数を付与
-- (元の注文行を保持したまま集計値を追加)
SELECT
order_id,
user_id,
amount,
COUNT(*) OVER (PARTITION BY user_id) AS user_total_orders,
SUM(amount) OVER (PARTITION BY user_id) AS user_total_amount
FROM orders;
GROUP BY のパフォーマンス
GROUP BY は内部でソートや一時テーブルを使うため、行数が多いほど遅くなります。以下のポイントを意識するだけで大幅に改善できます。
インデックスを使える状況にする
-- NG: ORDER_DATE にインデックスがあるのに全件GROUP BY後にWHERE
-- (HAVINGは集計後フィルターのためインデックスが使えない)
SELECT
DATE_FORMAT(order_date, '%Y-%m') AS ym,
COUNT(*) AS cnt
FROM orders
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
HAVING ym >= '2024-01'; -- NG: 全件集計してから絞り込む
-- OK: WHERE で先に絞り込んでからGROUP BY
SELECT
DATE_FORMAT(order_date, '%Y-%m') AS ym,
COUNT(*) AS cnt
FROM orders
WHERE order_date >= '2024-01-01' -- インデックスが効く
GROUP BY DATE_FORMAT(order_date, '%Y-%m');
EXPLAIN SELECT
category,
COUNT(*) AS cnt
FROM orders
JOIN products USING (product_id)
WHERE order_date >= '2024-01-01'
GROUP BY category
HAVING COUNT(*) >= 10;
-- Using temporary: 一時テーブルが作られている → 遅い
-- Using filesort: 追加ソートが走っている → 遅い
-- Using index: インデックスのみで処理 → 速い
-- カバリングインデックスを作ると Using temporary が消える
CREATE INDEX idx_orders_date_cat
ON orders (order_date, product_id);
① 集計前に WHERE で行数を最大限絞り込む
② GROUP BY の対象カラムにインデックスを作成する
③ SELECT に GROUP BY 以外のカラムを入れない(ウィンドウ関数が必要な場合は分離)
④ HAVING に頻繁に使う条件は、事前に WHERE で置き換えられないか検討する
⑤ 大量グループ → 少量のフィルタには CTE でステップを分ける
実践パターン集
パターン1:重複データの検出
-- 重複しているメールアドレスを一覧表示
SELECT
email,
COUNT(*) AS duplicate_count
FROM users
GROUP BY email
HAVING COUNT(*) >= 2
ORDER BY duplicate_count DESC;
-- 重複行の詳細も一緒に取得(サブクエリ版)
SELECT *
FROM users
WHERE email IN (
SELECT email
FROM users
GROUP BY email
HAVING COUNT(*) >= 2
)
ORDER BY email, created_at;
パターン2:月次アクティブユーザー数(MAU)の推移
WITH monthly_au AS (
SELECT
DATE_FORMAT(action_date, '%Y-%m') AS ym,
COUNT(DISTINCT user_id) AS mau
FROM user_actions
WHERE action_date >= '2024-01-01'
GROUP BY DATE_FORMAT(action_date, '%Y-%m')
)
SELECT
ym,
mau,
mau - LAG(mau) OVER (ORDER BY ym) AS mau_diff
FROM monthly_au
ORDER BY ym;
パターン3:ランク付け(グループ内 Top-N)
WITH ranked AS (
SELECT
o.user_id,
p.category,
COUNT(*) AS order_count,
RANK() OVER (
PARTITION BY p.category
ORDER BY COUNT(*) DESC
) AS rnk
FROM orders o
JOIN products p USING (product_id)
GROUP BY o.user_id, p.category
)
SELECT user_id, category, order_count, rnk
FROM ranked
WHERE rnk <= 3
ORDER BY category, rnk;
パターン4:クロス集計(ピボット)
-- MySQL
SELECT
DAYNAME(order_date) AS day_of_week,
COUNT(CASE WHEN status = 'confirmed' THEN 1 END) AS confirmed,
COUNT(CASE WHEN status = 'shipped' THEN 1 END) AS shipped,
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) AS cancelled,
COUNT(*) AS total
FROM orders
GROUP BY DAYNAME(order_date), DAYOFWEEK(order_date)
ORDER BY DAYOFWEEK(order_date);
よくある質問
COUNT(*)、「特定カラムが非NULLの行数で絞り込む」なら COUNT(カラム名) を使います。LEFT JOIN の後に NULL が混入している場合は特に意識してください。GROUP BY year_month)。ただし PostgreSQL・Oracle・SQL Server は標準SQLに従い非対応のため、移植性を考えると元の式をそのまま GROUP BY に書くのが安全です。HAVING COUNT(*) > 100 とするとテーブルの総行数が100超の場合のみ結果が返ります。ただし実務上は稀な使い方です。GROUP BY は集計関数(COUNT / SUM 等)と組み合わせて集計を行うためのものです。DISTINCT は重複行を除くだけで集計は行いません。単純に重複排除するだけなら DISTINCT の方がシンプルですが、集計が必要な場合は GROUP BY を使います。まとめ
GROUP BY + COUNT + HAVING の使いこなしポイントをまとめます。
- 実行順序:WHERE → GROUP BY → 集計 → HAVING → SELECT → ORDER BY の順を理解する
- WHERE vs HAVING:集計前の条件は WHERE(インデックスが効く)、集計後の条件は HAVING
- COUNT(*) vs COUNT(col):NULL を含めるか除くかで使い分ける
- 条件付きCOUNT:COUNT(CASE WHEN) で複数条件の件数を1クエリで取得
- LEFT JOIN + COUNT:COUNT(*) ではなく COUNT(右テーブルのカラム) を使う
- パフォーマンス:WHERE で事前絞り込み → GROUP BY カラムにインデックス
関連記事:COUNT関数完全ガイド(COUNT(*)/COUNT(列)/NULL挙動)、WHERE句完全ガイド(HAVING vs WHERE)、CASE WHEN + GROUP BYで年齢層・金額帯・期間別集計、GROUP BY + SUM で重複データを加算(集計)する方法

