【SQL】GROUP BYで件数をカウント&条件付きで絞り込む方法|HAVING・条件付きCOUNT・複合グループ化・パフォーマンス完全ガイド

【SQL】GROUP BYで件数をカウント&条件付きで絞り込む方法 SQL

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;
具体例:カテゴリ別の商品数(5件以上のみ)
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 の
対象 個々の行 グループ化後の集計結果
集計関数 使用不可 使用可能
インデックス 使える 使えない(集計後のため)
代表的な用途 期間絞り込み・フラグフィルター 件数・合計・平均の閾値フィルター
WHERE と HAVING の使い分け例
-- 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 でフィルタする
WHERE は集計前に行を絞り込むため、処理対象行数が減ってパフォーマンスが向上します。HAVING は全グループを集計してから絞るため、行数が多いほどコストが高くなります。「集計前に条件を適用できるか?」を常に意識して、できる限り WHERE に書くようにしましょう。

COUNT(*) と COUNT(カラム名) の違い

GROUP BY と組み合わせる際に COUNT の動作の違いが重要になります。

COUNT(*) vs COUNT(col) の動作差
-- 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 の使い分けポイント
COUNT(*):グループ内の全行数(NULL を含む)
COUNT(カラム名):そのカラムが非NULL の行数のみ
COUNT(DISTINCT カラム名):重複を除いたユニーク件数
COUNT の詳細な動作については COUNT関数完全ガイド も参照してください。

条件付き COUNT:グループ内で条件ごとの件数を出す

「1つのクエリで「完了件数」と「未完了件数」を同時に出したい」という場合、COUNT(CASE WHEN) または SUM(CASE WHEN) を使います。

COUNT(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;
SUM(CASE WHEN) との違い
-- 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;
HAVING で条件付きCOUNTの結果をフィルタ
-- 「キャンセル率が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 / Oracle / SQL Server での書き方
-- 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 を使うと、グループ別集計に加えて小計・合計行を自動で追加できます。

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 が必須です。

INNER JOIN + GROUP BY(基本)
-- 顧客ごとの注文件数と合計金額
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;
LEFT JOIN + GROUP BY(注文0件の顧客も出す)
-- 注文のない顧客も含め、顧客全員の注文件数を集計
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(*)に注意
LEFT JOIN 後に COUNT(*) を使うと、右テーブルが対応なし(NULL)でも1件としてカウントされます。注文のない顧客を「0件」と表示したい場合は COUNT(o.order_id) のようにNULLになるカラムを指定してください。

HAVING の複合条件(AND / OR / サブクエリ)

HAVING には複数の条件を組み合わせたり、サブクエリを使ったりできます。

HAVING の AND / OR 組み合わせ
-- 「注文件数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;
HAVING でサブクエリを使う(平均との比較)
-- 全体平均件数より多い注文数を持つユーザー
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 vs ウィンドウ関数の選択
-- 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 は内部でソートや一時テーブルを使うため、行数が多いほど遅くなります。以下のポイントを意識するだけで大幅に改善できます。

インデックスを使える状況にする

WHERE でインデックスを活用してから 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 でボトルネックを確認(MySQL)
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);
GROUP BY パフォーマンス改善チェックリスト
① 集計前に 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)

カテゴリ別に注文件数上位3ユーザーを取得
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);

よくある質問

QGROUP BY のカラムに集計関数は使えますか?
A直接は使えません。「月ごと」のように GROUP BY にも計算式が必要な場合は、サブクエリや CTE で先に計算カラムを作り、それを GROUP BY に指定します。一部のDBMS(Oracle 23ai 等)では SELECT のエイリアスを GROUP BY に使えますが、標準SQLでは非対応です。
QHAVING で COUNT(*) と COUNT(カラム名) どちらを使うべきですか?
A目的によります。「グループ内の全行数で絞り込む」なら COUNT(*)、「特定カラムが非NULLの行数で絞り込む」なら COUNT(カラム名) を使います。LEFT JOIN の後に NULL が混入している場合は特に意識してください。
QMySQLで GROUP BY に SELECT のエイリアスは使えますか?
AMySQLは GROUP BY に SELECT のエイリアスを使えます(例: GROUP BY year_month)。ただし PostgreSQL・Oracle・SQL Server は標準SQLに従い非対応のため、移植性を考えると元の式をそのまま GROUP BY に書くのが安全です。
QGROUP BY なしで HAVING を使うことはできますか?
Aできます。GROUP BY なしの場合はテーブル全体を1グループとして扱い、HAVING でその集計値に条件を付けられます。例: HAVING COUNT(*) > 100 とするとテーブルの総行数が100超の場合のみ結果が返ります。ただし実務上は稀な使い方です。
QGROUP BY と DISTINCT の違いは何ですか?
AGROUP 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 で重複データを加算(集計)する方法