グループ化の基礎 — GROUP BY と COUNT(*) で行をグループに畳み込む
SQL の集計の出発点が GROUP BY によるグループ化です。指定した列の値が等しい行を1つのグループにまとめ、グループごとに集計関数(COUNT・SUM など)を1つの値へ畳み込み(collapse)ます。「明細データ」から「サマリ」を作る最初の一歩です。
-- GROUP BY: category の値が同じ行を1つのグループにまとめる SELECT category, COUNT(*) AS order_count FROM orders GROUP BY category;
COUNT(*) はグループ内の行数を数え、NULL を含めて全行をカウントします。orders テーブルから、カテゴリ別の注文件数を求めてください。出力列は category, order_count。order_count の降順、件数が同じ場合は category の昇順で返してください。
| order_id | category | amount |
|---|---|---|
| 1 | Books | 1200 |
| 2 | Books | 800 |
| 3 | Toys | 3000 |
| 4 | Food | 500 |
| 5 | Toys | 1500 |
| 6 | Books | 2000 |
| category | order_count |
|---|---|
| Books | 3 |
| Toys | 2 |
| Food | 1 |
SELECT category, -- グループ化の基準となる列(SELECT句に出力可能) COUNT(*) AS order_count -- グループ内の行数を数える集計関数(NULLも含めて全行をカウント) FROM orders GROUP BY category -- 指定した列(category)の値が同じ行を1つのグループにまとめる ORDER BY order_count DESC, category; -- 件数が多い順(降順)、同数の場合はカテゴリ名の昇順に並べ替え /* 実行順序(SQLの論理的な評価順): 1. FROM orders → 行を読込 2. GROUP BY category → カテゴリでグループ化 3. COUNT(*) → 各グループの行数を集計 4. SELECT → category, order_count を出力 5. ORDER BY order_count DESC, category → 件数の多い順で並べ替え */
LEGEND
① FROM orders(6行)
FROM ordersorders テーブルの6行を読み込みます。category 列には Books / Toys / Food の3種類の値があり、まだ行はバラバラの状態です。| order_id | category | amount |
|---|---|---|
| 1 | Books | 1200 |
| 2 | Books | 800 |
| 3 | Toys | 3000 |
| 4 | Food | 500 |
| 5 | Toys | 1500 |
| 6 | Books | 2000 |
SELECT category, amount のように非集計列を裸で書くと PostgreSQL ではエラーになります。GROUP BY に書いた列か、SUM()・COUNT() などの集計関数のみが書けます。COUNT(*) はグループ内の行の総数を返します。後で学ぶ COUNT(列名) が NULL を除外するのとは対照的で、「件数」を素直に数えたいときは COUNT(*) が基本です。SELECT category, order_id, COUNT(*) ... GROUP BY category はエラーです。order_id はグループ内で値が一意に定まらないため。1グループに1つの値が決まる列(=キーか集計値)だけを選びましょう。WHERE COUNT(*) > 1 は構文エラーになります。集計結果でグループを絞るのは HAVING の仕事です(Q4で詳説)。WHERE はグループ化の前、生の行に対してのみ働きます。集計関数 — SUM / AVG / MAX / MIN でグループごとに数値を集計する
グループ化の真価は集計関数と組み合わせたときに発揮されます。1つのグループに対し、合計・平均・最大・最小などをそれぞれ1つの値として算出できます。複数の集計関数は1回のグループ化で同時に計算されます。
-- 集計関数はグループごとに1つの値を返す SUM(amount) -- 合計 AVG(amount) -- 平均(NULL は無視される) MAX(amount), MIN(amount) -- 最大・最小 ROUND(AVG(amount), 0) -- 小数桁の整形
AVG(amount) は NULL の行を分母にも分子にも含めません(無視する)。一方 COUNT(*) は NULL 行も数えるため、「AVG の分母」と「COUNT(*)」がずれることがあります。平均の整形には ROUND(値, 桁数) を使います。orders テーブルから、カテゴリ別に 件数・合計・平均(整数に丸め)・最大・最小を算出してください。出力列は category, order_count, total_amount, avg_amount, max_amount, min_amount。total_amount の降順で返してください。
| order_id | category | amount |
|---|---|---|
| 1 | Books | 1200 |
| 2 | Books | 800 |
| 3 | Books | 2000 |
| 4 | Toys | 3000 |
| 5 | Toys | 1500 |
| 6 | Food | 500 |
| category | order_count | total_amount | avg_amount | max_amount | min_amount |
|---|---|---|---|---|---|
| Toys | 2 | 4500 | 2250 | 3000 | 1500 |
| Books | 3 | 4000 | 1333 | 2000 | 800 |
| Food | 1 | 500 | 500 | 500 | 500 |
SELECT category, -- グループ化の基準列 COUNT(*) AS order_count, -- グループ内の全行の件数をカウント SUM(amount) AS total_amount, -- グループ内のamount(金額)の合計を算出 ROUND(AVG(amount), 0) AS avg_amount, -- 平均値を算出し、小数第1位を四捨五入して整数(0桁)に丸める MAX(amount) AS max_amount, -- グループ内の最大値を抽出 MIN(amount) AS min_amount -- グループ内の最小値を抽出 FROM orders GROUP BY category -- カテゴリ単位でグループ化 ORDER BY total_amount DESC; -- 合計金額の降順で並べ替え /* 実行順序(SQLの論理的な評価順): 1. FROM orders → 行を読込 2. GROUP BY category → カテゴリでグループ化 3. 各グループで集計 → COUNT/SUM/AVG/MAX/MIN 4. ORDER BY total_amount DESC → 合計の多い順で並べ替え */
LEGEND
① FROM orders(6行)
FROM ordersorders テーブルの6行を読み込みます。今回は amount 列の数値をグループごとに集計するのが目的です。| order_id | category | amount |
|---|---|---|
| 1 | Books | 1200 |
| 2 | Books | 800 |
| 3 | Books | 2000 |
| 4 | Toys | 3000 |
| 5 | Toys | 1500 |
| 6 | Food | 500 |
AVG(amount) は NULL の行を計算対象から外します。つまり平均の「分母」は COUNT(amount)(NULL 除外)であり、COUNT(*)(全行)とは一致しないことがあります。意図した分母になっているかを意識すると、平均値の解釈ミスを防げます。ROUND(AVG(amount), 0) は平均を小数0桁(整数)に丸めます。レポートでは桁を揃えると一気に読みやすくなります。割合計算なら ROUND(..., 1) のように桁数を変えるだけで調整できます。SUM(amount) / COUNT(*) を整数型のまま計算すると、小数が切り捨てられ平均が狂います。素直に AVG() を使うか、SUM(amount) * 1.0 / COUNT(*) のように小数化してください。MAX() を使うと辞書順('Z' > 'A')で評価されます。対象列の型を意識しないと「最大の注文額」のつもりが別物になります。COUNT(DISTINCT ...) や FILTER を足せば「ユニーク顧客数」「完了率」まで一気に取れ、1本のクエリがそのままダッシュボードの裏側になります。複数列での集計 — GROUP BY に2列指定して組み合わせ単位で集計する
GROUP BY には複数の列を指定できます。その場合、グループは「列の組み合わせが同じ行」ごとに作られます。GROUP BY category, status なら (Books, completed) と (Books, cancelled) は別グループです。切り口を掛け合わせたクロス集計の基礎になります。
-- (category, status) の組み合わせごとにグループ化 SELECT category, status, COUNT(*), SUM(amount) FROM orders GROUP BY category, status;
orders テーブルから、カテゴリ × ステータスの組み合わせごとに、件数と合計金額を求めてください。出力列は category, status, order_count, total_amount。category, status の昇順で返してください。
| order_id | category | status | amount |
|---|---|---|---|
| 1 | Books | completed | 1200 |
| 2 | Books | completed | 800 |
| 3 | Books | cancelled | 2000 |
| 4 | Toys | completed | 3000 |
| 5 | Toys | cancelled | 1500 |
| 6 | Toys | cancelled | 1000 |
| 7 | Food | completed | 500 |
| category | status | order_count | total_amount |
|---|---|---|---|
| Books | cancelled | 1 | 2000 |
| Books | completed | 2 | 2000 |
| Food | completed | 1 | 500 |
| Toys | cancelled | 2 | 2500 |
| Toys | completed | 1 | 3000 |
SELECT category, status, COUNT(*) AS order_count, -- 組み合わせごとの件数 SUM(amount) AS total_amount -- 組み合わせごとの合計金額 FROM orders GROUP BY category, status -- カテゴリとステータスの「組み合わせ」単位でグループ化 ORDER BY category, status; -- 複数列での並べ替え(まずカテゴリ昇順、次にステータス昇順) /* 実行順序(SQLの論理的な評価順): 1. FROM orders → 行を読込 2. GROUP BY category, status → 組み合わせでグループ化 3. COUNT(*) / SUM(amount) → 各組で集計 4. ORDER BY category, status → 名前順に整列 */
LEGEND
① FROM orders(7行)
FROM ordersorders テーブルの7行を読み込みます。category(3種)と status(completed/cancelled)の組み合わせで集計します。| order_id | category | status | amount |
|---|---|---|---|
| 1 | Books | completed | 1200 |
| 2 | Books | completed | 800 |
| 3 | Books | cancelled | 2000 |
| 4 | Toys | completed | 3000 |
| 5 | Toys | cancelled | 1500 |
| 6 | Toys | cancelled | 1000 |
| 7 | Food | completed | 500 |
SELECT category, status ... GROUP BY category はエラー(status がグループ内で一意に定まらない)。出したい粒度(組み合わせ単位)と GROUP BY の列をそろえてください。GROUP BY category, status と GROUP BY status, category はグループの集合としては同一です(並び順は ORDER BY が決める)。GROUP BY の列順は結果の意味を変えない、と理解しておくと混乱しません。SUM(CASE WHEN status='completed' THEN amount ELSE 0 END) のような条件付き集計で列に展開できます。複数列 GROUP BY で粒度を作り、条件付き集計で横に開く——この2段構えが実務のクロス集計の王道です(条件付き集計は Q5 で扱います)。WHERE と HAVING — グループ化の前と後でフィルタを使い分ける
フィルタには2種類あります。WHERE はグループ化の「前」に生の行を絞り、HAVING はグループ化・集計の「後」にグループを絞ります。集計関数で条件を付けたいときは HAVING の出番です。両者の違いは SQL の実行順序を理解する鍵です。
-- 評価順: FROM → WHERE → GROUP BY → 集計 → HAVING → SELECT → ORDER BY SELECT category, SUM(amount) FROM orders WHERE status = 'completed' -- ① 行を絞る(集計前) GROUP BY category HAVING SUM(amount) >= 2000 -- ② グループを絞る(集計後)
orders テーブルから、completed の注文だけを対象に、カテゴリ別合計金額が 2000 以上のカテゴリを求めてください。出力列は category, order_count, total_amount。total_amount の降順で返してください。
| order_id | category | status | amount |
|---|---|---|---|
| 1 | Books | completed | 1200 |
| 2 | Books | completed | 800 |
| 3 | Books | cancelled | 5000 |
| 4 | Toys | completed | 3000 |
| 5 | Toys | completed | 1500 |
| 6 | Food | completed | 500 |
| 7 | Food | cancelled | 9000 |
| category | order_count | total_amount |
|---|---|---|
| Toys | 2 | 4500 |
| Books | 2 | 2000 |
SELECT category, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders WHERE status = 'completed' -- 【集計前フィルタ】グループ化の「前」に、完了した注文のみに行を絞り込む GROUP BY category -- カテゴリ単位でグループ化 HAVING SUM(amount) >= 2000 -- 【集計後フィルタ】グループ化・集計の「後」に、合計金額が2000以上のグループだけを残す ORDER BY total_amount DESC; -- 合計金額の降順で並べ替え /* 実行順序(SQLの論理的な評価順): 1. FROM orders → 行を読込 2. WHERE status='completed' → 完了行に絞る 3. GROUP BY category → カテゴリでグループ化 4. HAVING SUM(amount) → しきい値以上のグループを残す 5. ORDER BY total_amount DESC → 合計の多い順で並べ替え */
LEGEND
① FROM orders(7行)
FROM orders7行を読み込みます。cancelled の id 3(5000)・id 7(9000) は高額ですが、この後の WHERE で除外される点に注目してください。| order_id | category | status | amount |
|---|---|---|---|
| 1 | Books | completed | 1200 |
| 2 | Books | completed | 800 |
| 3 | Books | cancelled | 5000 |
| 4 | Toys | completed | 3000 |
| 5 | Toys | completed | 1500 |
| 6 | Food | completed | 500 |
| 7 | Food | cancelled | 9000 |
SUM(amount) >= 2000 のような集計結果の条件は HAVING 専用です。WHERE 段階では SUM はまだ計算されていないため参照できません。「集計値で絞る=HAVING」と紐づけて覚えましょう。WHERE SUM(amount) >= 2000 は構文エラーです。集計は WHERE の後の段階で行われるため、WHERE からは集計値が見えません。集計結果の条件は必ず HAVING へ。HAVING status = 'completed'(しかも status は GROUP BY に無い)は誤り・非効率です。生の列値での絞り込みは WHERE に書き、グループ化前に行数を減らすのが正解です。COUNT(DISTINCT) と条件付き集計 — ユニーク数・FILTER・NULLIF を組み合わせる
グループ化の総仕上げとして、COUNT の3つの顔と条件付き集計を学びます。COUNT(*) は全行、COUNT(列) は NULL を除いた行、COUNT(DISTINCT 列) は重複を除いたユニーク数を数えます。FILTER は特定の集計だけに条件を付ける構文です。
COUNT(*) -- 全行 COUNT(DISTINCT customer_id) -- 重複を除いた人数 COUNT(*) FILTER (WHERE status = 'completed') -- 条件に合う行だけ ... / NULLIF(COUNT(*), 0) -- ゼロ除算回避
COUNT(*)=3・COUNT(DISTINCT customer_id)=2 なら、「3注文を2人が出した(=1人がリピート)」と読めます。FILTER は特定の条件を満たす行だけを数えるため、CASE WHEN を使わず簡潔に条件付き集計が書けます(PostgreSQL の構文)。orders テーブルから、カテゴリ別に 総注文数・ユニーク顧客数・完了注文数・完了率(%)を求めてください。出力列は category, total_orders, unique_customers, completed_orders, completion_pct。total_orders 降順、同数なら category 昇順で返してください。
| order_id | category | customer_id | status |
|---|---|---|---|
| 1 | Books | 101 | completed |
| 2 | Books | 101 | completed |
| 3 | Books | 102 | cancelled |
| 4 | Toys | 103 | completed |
| 5 | Toys | 103 | completed |
| 6 | Toys | 104 | cancelled |
| 7 | Food | 105 | completed |
| 8 | Food | 105 | cancelled |
| category | total_orders | unique_customers | completed_orders | completion_pct |
|---|---|---|---|---|
| Books | 3 | 2 | 2 | 66.7 |
| Toys | 3 | 2 | 2 | 66.7 |
| Food | 2 | 1 | 1 | 50.0 |
SELECT category, COUNT(*) AS total_orders, -- 【全行数】グループ内の全注文数をカウント COUNT(DISTINCT customer_id) AS unique_customers, -- 【ユニーク数】顧客IDの重複を除外し、何人の顧客がいるかをカウント COUNT(*) FILTER (WHERE status = 'completed') AS completed_orders, -- 【条件付き集計】完了ステータスの行だけを対象にカウント ROUND( COUNT(*) FILTER (WHERE status = 'completed') * 100.0 -- 完了数を100.0倍して実数にし、パーセント表記の分子にする / NULLIF(COUNT(*), 0), 1) AS completion_pct -- 分母が0の時のゼロ除算エラーをNULLIFで防ぎ、小数を第1位に丸める FROM orders GROUP BY category -- カテゴリ単位でグループ化 ORDER BY total_orders DESC, category; -- 注文数降順、同数の場合はカテゴリ名昇順で並べ替え /* 実行順序(SQLの論理的な評価順): 1. FROM orders → 行を読込 2. GROUP BY category → カテゴリでグループ化 3. 各グループで集計 → COUNT/DISTINCT顧客/完了率 4. ORDER BY total_orders DESC, category → 件数の多い順で並べ替え */
LEGEND
① FROM orders(8行)
FROM orders8行を読み込みます。customer_id には重複(101が2回、103が2回、105が2回)があり、status には completed / cancelled が混在します。| order_id | category | customer_id | status |
|---|---|---|---|
| 1 | Books | 101 | completed |
| 2 | Books | 101 | completed |
| 3 | Books | 102 | cancelled |
| 4 | Toys | 103 | completed |
| 5 | Toys | 103 | completed |
| 6 | Toys | 104 | cancelled |
| 7 | Food | 105 | completed |
| 8 | Food | 105 | cancelled |
COUNT(*)=全行、COUNT(列)=NULL を除く行、COUNT(DISTINCT 列)=重複も NULL も除いたユニーク数。「注文数」は COUNT(*)、「顧客数」は COUNT(DISTINCT customer_id) のように、数えたい対象で正しく選ぶことが集計の精度を決めます。COUNT(*) FILTER (WHERE status='completed') は「完了注文だけ数える」を一行で表現します。同じグループ化の中で、指標ごとに別々の条件をかけられるのが強力で、PostgreSQL では CASE WHEN より読みやすい第一選択です。/ NULLIF(COUNT(*), 0) は分母が0のとき NULL を返し、ゼロ除算エラーを防ぎます。「注文ゼロのカテゴリの完了率」は0% ではなく定義不能(NULL)が正しく、ROUND と組み合わせて読みやすい割合に整形します。COUNT(*)(=3)を顧客数と誤れば、実際の2人を3人と過大評価します。人数を数えるなら必ず COUNT(DISTINCT customer_id) を使ってください。COUNT(*) FILTER(...) * 100.0 / COUNT(*) のように分母を生のままにすると、対象行が0件のグループで division by zero エラーになります。割合計算では分母を必ず NULLIF(分母, 0) で包んでください。SUM(amount) FILTER (WHERE status='completed')(完了売上)、COUNT(*) FILTER (WHERE amount >= 5000)(高額注文数)などを同じ SELECT に並べ、カテゴリ別 KPI 表を一発で生成します。同じデータを何度もスキャンせず、条件ごとの集計を横に並べられるため、ダッシュボード用の集計クエリが劇的に簡潔になります。CASE WHEN でも同等の表現は可能ですが、PostgreSQL では FILTER の方が意図が明確で保守しやすい第一選択です。