「年齢層ごとの売上合計を出したい」「購入金額帯別に顧客数を数えたい」「月次・四半期別に件数と平均を比べたい」——こうした任意の区間でデータをグループ分けして集計する操作は、データ分析の現場で日常的に発生します。
SQLでこれを実現するのが CASE WHEN + GROUP BY の組み合わせです。本記事では基本パターンから、DBMS別のエイリアス問題・CTEを使ったクリーンな書き方・ROLLUPによる小計追加・ウィンドウ関数での構成比計算まで、実務で使えるパターンを網羅します。
サンプルデータ
以降の解説では下記のテーブルを使います。
CREATE TABLE customers (
customer_id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
age INT, -- NULL あり(年齢未登録のケースを含む)
prefecture VARCHAR(20)
);
CREATE TABLE sales (
sale_id INT PRIMARY KEY,
customer_id INT,
amount DECIMAL(10,2) NOT NULL,
sale_date DATE NOT NULL
);
INSERT INTO customers VALUES
(1, '田中', 28, '東京'),
(2, '佐藤', 35, '大阪'),
(3, '鈴木', 42, '東京'),
(4, '高橋', 31, '名古屋'),
(5, '伊藤', 55, '大阪'),
(6, '渡辺', NULL, '東京'), -- 年齢未登録
(7, '山本', 23, '福岡');
INSERT INTO sales VALUES
(101, 1, 12000, '2024-01-15'),
(102, 2, 35000, '2024-01-20'),
(103, 3, 8500, '2024-02-10'),
(104, 4, 22000, '2024-02-28'),
(105, 5, 45000, '2024-03-05'),
(106, 1, 9800, '2024-03-12'),
(107, 7, 15000, '2024-03-20');
基本パターン:CASE WHEN で年齢層グループを作る
CASE WHEN で連続値(年齢・金額・スコアなど)を任意の区間に分類し、GROUP BY でその区間ごとに集計します。
SELECT
CASE
WHEN c.age BETWEEN 20 AND 29 THEN '20代'
WHEN c.age BETWEEN 30 AND 39 THEN '30代'
WHEN c.age BETWEEN 40 AND 49 THEN '40代'
WHEN c.age >= 50 THEN '50代以上'
WHEN c.age IS NULL THEN '年齢未登録'
ELSE '20歳未満'
END AS age_group,
COUNT(s.sale_id) AS sales_count,
SUM(s.amount) AS total_amount,
ROUND(AVG(s.amount), 0) AS avg_amount,
MAX(s.amount) AS max_amount
FROM customers c
JOIN sales s ON c.customer_id = s.customer_id
GROUP BY
CASE
WHEN c.age BETWEEN 20 AND 29 THEN '20代'
WHEN c.age BETWEEN 30 AND 39 THEN '30代'
WHEN c.age BETWEEN 40 AND 49 THEN '40代'
WHEN c.age >= 50 THEN '50代以上'
WHEN c.age IS NULL THEN '年齢未登録'
ELSE '20歳未満'
END
ORDER BY MIN(c.age); -- 年齢順に並べる
実行結果イメージ:
| age_group | sales_count | total_amount | avg_amount | max_amount |
|---|---|---|---|---|
| 20代 | 3 | 36,800 | 12,267 | 15,000 |
| 30代 | 2 | 57,000 | 28,500 | 35,000 |
| 40代 | 1 | 8,500 | 8,500 | 8,500 |
| 50代以上 | 1 | 45,000 | 45,000 | 45,000 |
age_group というエイリアスを定義しても、GROUP BY でそのエイリアスを参照できるかはDBMSによって異なります(後述)。確実に動かすには GROUP BY にも同じ CASE WHEN 式を繰り返すか、CTEを使います。GROUP BY でエイリアスが使えるかどうか(DBMS別)
SELECT句のエイリアスをGROUP BYで参照できるかどうかはDBMSによって異なります。
| DBMS | GROUP BY でエイリアス使用 | 備考 |
|---|---|---|
| MySQL | ○ 使える | MySQLはエイリアスをGROUP BYで参照できる独自拡張あり |
| PostgreSQL | ○ 使える | エイリアス参照をサポート |
| Oracle | × 使えない | SQL標準に従い、エイリアスはGROUP BY/WHERE不可 |
| SQL Server | × 使えない | 同上。サブクエリまたはCTEで対応 |
-- MySQL / PostgreSQL はこの書き方が使える
SELECT
CASE
WHEN age BETWEEN 20 AND 29 THEN '20代'
WHEN age BETWEEN 30 AND 39 THEN '30代'
WHEN age BETWEEN 40 AND 49 THEN '40代'
ELSE 'その他'
END AS age_group,
SUM(amount) AS total_amount,
AVG(amount) AS avg_amount
FROM customers
JOIN sales USING (customer_id)
GROUP BY age_group -- ← エイリアスをそのまま使える
ORDER BY age_group;
-- CTEを使えばOracle/SQL Serverでも、かつ可読性も高い
WITH age_grouped AS (
SELECT
s.amount,
CASE
WHEN c.age BETWEEN 20 AND 29 THEN '20代'
WHEN c.age BETWEEN 30 AND 39 THEN '30代'
WHEN c.age BETWEEN 40 AND 49 THEN '40代'
WHEN c.age >= 50 THEN '50代以上'
WHEN c.age IS NULL THEN '年齢未登録'
ELSE 'その他'
END AS age_group
FROM customers c
JOIN sales s ON c.customer_id = s.customer_id
)
SELECT
age_group,
COUNT(*) AS sales_count,
SUM(amount) AS total_amount,
ROUND(AVG(amount), 0) AS avg_amount
FROM age_grouped
GROUP BY age_group
ORDER BY MIN(age_group);
グループの表示順を意図通りに制御する
「20代→30代→40代→50代以上」のように年齢順に表示したい場合、文字列エイリアスをそのままORDER BYにかけると辞書順(20代・30代・40代・50代以上は偶然正しい)になりますが、「10代」や「その他」を含む場合は明示的な順序指定が必要です。
-- 方法1: グループに数値の順序キーを持たせる
WITH age_grouped AS (
SELECT
amount,
CASE
WHEN age < 20 THEN 1
WHEN age BETWEEN 20 AND 29 THEN 2
WHEN age BETWEEN 30 AND 39 THEN 3
WHEN age BETWEEN 40 AND 49 THEN 4
WHEN age >= 50 THEN 5
ELSE 9 -- NULL/その他を末尾に
END AS age_order,
CASE
WHEN age < 20 THEN '10代以下'
WHEN age BETWEEN 20 AND 29 THEN '20代'
WHEN age BETWEEN 30 AND 39 THEN '30代'
WHEN age BETWEEN 40 AND 49 THEN '40代'
WHEN age >= 50 THEN '50代以上'
ELSE '年齢未登録'
END AS age_group
FROM customers c
JOIN sales s ON c.customer_id = s.customer_id
)
SELECT
age_group,
COUNT(*) AS sales_count,
SUM(amount) AS total_amount,
ROUND(AVG(amount), 0) AS avg_amount
FROM age_grouped
GROUP BY age_group, age_order
ORDER BY age_order; -- 数値キーでソートすれば確実
-- 方法2: ORDER BY で CASE WHEN を直接使う
ORDER BY
CASE age_group
WHEN '20代' THEN 1
WHEN '30代' THEN 2
WHEN '40代' THEN 3
WHEN '50代以上' THEN 4
ELSE 9
END;
ROLLUPで小計・合計行を自動追加する
GROUP BY にROLLUPを付けると、各グループの集計行に加えて全体合計行が自動で追加されます。手動でUNION ALLで合計行を加える必要がなくなります。
-- MySQL / Oracle / PostgreSQL
SELECT
COALESCE(
CASE
WHEN age BETWEEN 20 AND 29 THEN '20代'
WHEN age BETWEEN 30 AND 39 THEN '30代'
WHEN age BETWEEN 40 AND 49 THEN '40代'
WHEN age >= 50 THEN '50代以上'
ELSE 'その他'
END,
'【合計】') AS age_group, -- ROLLUPの合計行は NULL になるので COALESCE で置換
COUNT(*) AS sales_count,
SUM(s.amount) AS total_amount,
ROUND(AVG(s.amount), 0) AS avg_amount
FROM customers c
JOIN sales s ON c.customer_id = s.customer_id
GROUP BY ROLLUP (
CASE
WHEN c.age BETWEEN 20 AND 29 THEN '20代'
WHEN c.age BETWEEN 30 AND 39 THEN '30代'
WHEN c.age BETWEEN 40 AND 49 THEN '40代'
WHEN c.age >= 50 THEN '50代以上'
ELSE 'その他'
END
);
-- SQL Server: 構文が同じ(GROUP BY ROLLUP (...))
WITH age_grouped AS (
SELECT
s.amount,
CASE
WHEN c.age BETWEEN 20 AND 29 THEN '20代'
WHEN c.age BETWEEN 30 AND 39 THEN '30代'
WHEN c.age BETWEEN 40 AND 49 THEN '40代'
WHEN c.age >= 50 THEN '50代以上'
ELSE 'その他'
END AS age_group
FROM customers c
JOIN sales s ON c.customer_id = s.customer_id
)
SELECT
COALESCE(age_group, '【合計】') AS age_group,
COUNT(*) AS sales_count,
SUM(amount) AS total_amount,
ROUND(AVG(amount), 0) AS avg_amount
FROM age_grouped
GROUP BY ROLLUP (age_group)
ORDER BY GROUPING(age_group), age_group;
-- GROUPING() は合計行で1を返すため、合計行を末尾に表示できる
ウィンドウ関数でグループ内の構成比・累計を算出する
各グループの合計が全体に占める割合(構成比)や累計を計算したい場合は、ウィンドウ関数を使います。サブクエリで合計を計算するよりシンプルに書けます。
WITH age_grouped AS (
SELECT
s.amount,
CASE
WHEN c.age BETWEEN 20 AND 29 THEN '20代'
WHEN c.age BETWEEN 30 AND 39 THEN '30代'
WHEN c.age BETWEEN 40 AND 49 THEN '40代'
WHEN c.age >= 50 THEN '50代以上'
ELSE 'その他'
END AS age_group
FROM customers c
JOIN sales s ON c.customer_id = s.customer_id
),
group_totals AS (
SELECT
age_group,
COUNT(*) AS sales_count,
SUM(amount) AS group_total
FROM age_grouped
GROUP BY age_group
)
SELECT
age_group,
sales_count,
group_total,
-- 全体合計(ウィンドウ関数でパーティションなし = 全行対象)
SUM(group_total) OVER () AS grand_total,
-- 構成比(小数点1桁)
ROUND(100.0 * group_total / SUM(group_total) OVER (), 1) AS pct,
-- 累計(年齢順に並べた場合の累積売上)
SUM(group_total) OVER (ORDER BY age_group ROWS UNBOUNDED PRECEDING) AS running_total
FROM group_totals
ORDER BY age_group;
実行結果イメージ:
| age_group | sales_count | group_total | grand_total | pct | running_total |
|---|---|---|---|---|---|
| 20代 | 3 | 36,800 | 147,300 | 25.0% | 36,800 |
| 30代 | 2 | 57,000 | 147,300 | 38.7% | 93,800 |
| 40代 | 1 | 8,500 | 147,300 | 5.8% | 102,300 |
| 50代以上 | 1 | 45,000 | 147,300 | 30.6% | 147,300 |
HAVINGで集計後のグループを絞り込む
「売上合計が1万円以上のグループだけ表示したい」など、集計結果に条件をかけるにはHAVINGを使います。WHERE句は集計前の絞り込み、HAVINGは集計後の絞り込みです。
WITH age_grouped AS (
SELECT
s.amount,
CASE
WHEN c.age BETWEEN 20 AND 29 THEN '20代'
WHEN c.age BETWEEN 30 AND 39 THEN '30代'
WHEN c.age BETWEEN 40 AND 49 THEN '40代'
WHEN c.age >= 50 THEN '50代以上'
ELSE 'その他'
END AS age_group
FROM customers c
JOIN sales s ON c.customer_id = s.customer_id
)
SELECT
age_group,
COUNT(*) AS sales_count,
SUM(amount) AS total_amount,
ROUND(AVG(amount), 0) AS avg_amount
FROM age_grouped
GROUP BY age_group
HAVING COUNT(*) >= 2 -- 購入件数2件以上のグループのみ
AND SUM(amount) >= 20000 -- かつ合計2万円以上
ORDER BY total_amount DESC;
WHERE: グループ化・集計の前にフィルタ(個々の行に条件)HAVING: グループ化・集計の後にフィルタ(集計結果に条件)例: 「2024年の売上のみ対象 (WHERE) 」かつ「合計10万円以上のグループのみ (HAVING)」
NULLの扱い:ELSE句で必ず受け止める
年齢がNULLの顧客はどのCASE条件にも当てはまらず、ELSE句がなければNULLとして分類されます。NULLのグループはGROUP BYでは1つのグループとして扱われますが、意図が不明確になります。必ずELSE句で「年齢未登録」などのラベルを付けてください。
-- NG: ELSE なし → age IS NULLの顧客がNULLグループに
SELECT
CASE
WHEN age BETWEEN 20 AND 29 THEN '20代'
WHEN age BETWEEN 30 AND 39 THEN '30代'
-- ELSE なし: NULL の行は NULL グループに入る
END AS age_group,
COUNT(*) AS cnt
FROM customers
GROUP BY age_group;
-- NULL グループが表示されるが意味が不明確
-- OK: ELSE で明示的にラベル付け
SELECT
CASE
WHEN age BETWEEN 20 AND 29 THEN '20代'
WHEN age BETWEEN 30 AND 39 THEN '30代'
WHEN age IS NULL THEN '年齢未登録'
ELSE 'その他'
END AS age_group,
COUNT(*) AS cnt
FROM customers
GROUP BY age_group;
実務バリエーション:年齢以外のグループ化パターン
購入金額帯別の顧客数と平均単価
WITH amount_grouped AS (
SELECT
customer_id,
SUM(amount) AS total_purchase,
CASE
WHEN SUM(amount) < 10000 THEN '1万円未満'
WHEN SUM(amount) BETWEEN 10000 AND 29999 THEN '1〜3万円'
WHEN SUM(amount) BETWEEN 30000 AND 49999 THEN '3〜5万円'
WHEN SUM(amount) >= 50000 THEN '5万円以上'
END AS purchase_tier
FROM sales
GROUP BY customer_id
)
SELECT
purchase_tier,
COUNT(*) AS customer_count,
ROUND(AVG(total_purchase), 0) AS avg_purchase,
SUM(total_purchase) AS tier_total
FROM amount_grouped
GROUP BY purchase_tier
ORDER BY MIN(total_purchase);
期間(四半期)別の売上集計
SELECT
CASE
WHEN EXTRACT(MONTH FROM sale_date) BETWEEN 1 AND 3 THEN 'Q1'
WHEN EXTRACT(MONTH FROM sale_date) BETWEEN 4 AND 6 THEN 'Q2'
WHEN EXTRACT(MONTH FROM sale_date) BETWEEN 7 AND 9 THEN 'Q3'
WHEN EXTRACT(MONTH FROM sale_date) BETWEEN 10 AND 12 THEN 'Q4'
END AS quarter,
EXTRACT(YEAR FROM sale_date) AS fiscal_year,
COUNT(*) AS sales_count,
SUM(amount) AS total_amount,
ROUND(AVG(amount), 0) AS avg_amount
FROM sales
GROUP BY
EXTRACT(YEAR FROM sale_date),
CASE
WHEN EXTRACT(MONTH FROM sale_date) BETWEEN 1 AND 3 THEN 'Q1'
WHEN EXTRACT(MONTH FROM sale_date) BETWEEN 4 AND 6 THEN 'Q2'
WHEN EXTRACT(MONTH FROM sale_date) BETWEEN 7 AND 9 THEN 'Q3'
WHEN EXTRACT(MONTH FROM sale_date) BETWEEN 10 AND 12 THEN 'Q4'
END
ORDER BY fiscal_year, quarter;
スコア帯別(会員ランク・評価スコアなど)
WITH score_grouped AS (
SELECT
customer_id,
loyalty_score,
CASE
WHEN loyalty_score >= 90 THEN 'プラチナ'
WHEN loyalty_score >= 70 THEN 'ゴールド'
WHEN loyalty_score >= 50 THEN 'シルバー'
ELSE 'ブロンズ'
END AS member_rank
FROM customers
)
SELECT
sg.member_rank,
COUNT(DISTINCT sg.customer_id) AS member_count,
SUM(s.amount) AS total_sales,
ROUND(AVG(s.amount), 0) AS avg_sale_per_tx,
ROUND(SUM(s.amount) / COUNT(DISTINCT sg.customer_id), 0) AS avg_sales_per_member
FROM score_grouped sg
JOIN sales s ON sg.customer_id = s.customer_id
GROUP BY sg.member_rank
ORDER BY MIN(sg.loyalty_score) DESC;
都道府県(地域)別集計
WITH region_grouped AS (
SELECT
c.prefecture,
s.amount,
CASE c.prefecture
WHEN '東京' THEN '関東'
WHEN '神奈川' THEN '関東'
WHEN '埼玉' THEN '関東'
WHEN '千葉' THEN '関東'
WHEN '大阪' THEN '関西'
WHEN '京都' THEN '関西'
WHEN '兵庫' THEN '関西'
ELSE '其他地域'
END AS region
FROM customers c
JOIN sales s ON c.customer_id = s.customer_id
)
SELECT
region,
COUNT(*) AS sales_count,
SUM(amount) AS total_amount,
ROUND(AVG(amount), 0) AS avg_amount
FROM region_grouped
GROUP BY region
ORDER BY total_amount DESC;
よくある質問
ORDER BY CASE age_group WHEN '20代' THEN 1 WHEN '30代' THEN 2 ... END。または CTE でソートキー列(age_order)を数値で持たせ、ORDER BY age_order とします。GROUP BY ROLLUP (...)を使うと自動で全体合計行が追加されます。ROLLUPの合計行はグループ列がNULLになるため、COALESCE(age_group, '【合計】')でラベルを付けてください。ROLLUP非対応の環境ではUNION ALL SELECT '合計', COUNT(*), SUM(amount), AVG(amount) FROM ...で手動追加します。SUM(group_total) OVER () で全体合計を計算し、ROUND(100.0 * group_total / SUM(group_total) OVER (), 1) で構成比を算出します。本記事の「ウィンドウ関数で構成比を算出する」セクションのクエリをそのまま使えます。WHERE age IS NOT NULL。あるいは CASE の ELSE 句で「年齢未登録」ラベルを付けて含めた上で、HAVING で除外もできます: HAVING age_group != '年齢未登録'(MySQL・PostgreSQL)。まとめ
CASE WHEN + GROUP BY の組み合わせは、連続値を任意のグループに分類して集計するデータ分析の基本テクニックです。
- 基本形: CASE WHEN で区間を定義 → GROUP BY + SUM/AVG/COUNT で集計
- ELSE句必須: NULLや想定外の値を必ずELSEで受け止め、意図しないNULLグループを防ぐ
- CTE推奨: CASE WHEN を1か所に書き、GROUP BY はエイリアスで参照。全DB対応で可読性も高い
- GROUP BY ROLLUP: 合計行を自動追加。COALESCEでNULLラベルを「合計」に変換
- ウィンドウ関数: SUM OVER () で全体合計を取得し、構成比・累計を1クエリで算出
- HAVING: 集計後の絞り込みに使用。「合計N件以上・N円以上のグループのみ」表示に便利
関連記事:CASE WHEN完全ガイド、GROUP BY + SUMで集計する方法、AVG関数で平均を求める方法、SUM関数完全ガイド