結合してから集計 — JOIN した結果を GROUP BY して親ごとに子を数える
実務のデータは複数テーブルに分かれています。JOIN で結合してから GROUP BY すると、「親(顧客)ごとに子(注文)を集計する」が表現できます。子が1件も無い親も残したいときは LEFT JOIN を使います。
-- customers を軸に orders を左外部結合してから集計 SELECT c.name, COUNT(o.order_id), SUM(o.amount) FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id GROUP BY c.customer_id, c.name;
COUNT(*) はその NULL 行も1件と数えてしまうのに対し、COUNT(o.order_id) はNULL を無視して0件と正しく数えます。合計は COALESCE(SUM(...), 0) で NULL を0に整えます。customers と orders を結合し、顧客ごとの注文件数と合計金額を求めてください。注文が1件も無い顧客も 0件・0円で残すこと。出力列は name, order_count, total_amount。total_amount の降順、同額なら name の昇順で返してください。
| customer_id | name |
|---|---|
| 1 | Alice |
| 2 | Bob |
| 3 | Carol |
| order_id | customer_id | amount |
|---|---|---|
| 101 | 1 | 1200 |
| 102 | 1 | 800 |
| 103 | 2 | 3000 |
| 104 | 2 | 1500 |
| 105 | 2 | 500 |
| name | order_count | total_amount |
|---|---|---|
| Bob | 3 | 5000 |
| Alice | 2 | 2000 |
| Carol | 0 | 0 |
SELECT c.name, COUNT(o.order_id) AS order_count, -- NULLを無視して注文行だけ数える(注文0件の顧客は0になる) COALESCE(SUM(o.amount), 0) AS total_amount -- 合計がNULL(注文なし)の場合は0に置き換える FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id -- 注文が無い顧客も残す左外部結合 GROUP BY c.customer_id, c.name -- 顧客単位でグループ化(主キーと表示名で束ねる) ORDER BY total_amount DESC, c.name; -- 合計金額の降順、同額は名前の昇順 /* 実行順序(SQLの論理的な評価順): 1. FROM customers LEFT JOIN orders → 全顧客を保持 2. GROUP BY c.customer_id, c.name → 顧客でグループ化 3. 集計 → COUNT・SUM(NULLは0補正) 4. ORDER BY total_amount DESC, name → 合計の多い順で並べ替え */
LEGEND
① FROM customers LEFT JOIN orders
FROM customers LEFT JOIN orderscustomers を軸に orders を左外部結合します。Carol は対応する注文が無いため、order_id・amount が NULL の1行として残ります(この NULL の扱いが後で効いてきます)。| customer_id | name | order_id | amount |
|---|---|---|---|
| 1 | Alice | 101 | 1200 |
| 1 | Alice | 102 | 800 |
| 2 | Bob | 103 | 3000 |
| 2 | Bob | 104 | 1500 |
| 2 | Bob | 105 | 500 |
| 3 | Carol | NULL | NULL |
COUNT(*) はこの行も1と数えてしまいますが、COUNT(o.order_id) は NULL を無視して0と数えます。「子の件数」は必ず子テーブルの列で COUNT するのが鉄則です。SUM は 0 ではなく NULL です。COALESCE(SUM(o.amount), 0) で NULL を 0 に置き換え、「注文なし=0円」という直感どおりの表示にできます。COUNT(*) を使うと、注文0件の Carol が「1件」と誤カウントされます。結合先の列 COUNT(o.order_id) を使えば NULL 行は除かれ、正しく0になります。INNER JOIN を使うと、Carol は結果から丸ごと脱落します。親を全件残したいなら LEFT JOIN。要件(0件を残すか)でJOIN種別を選びましょう。ON 句に条件を書く(WHERE に書くと外部結合が内部結合化して0件が消える)のがコツです。期間でグループ化 — DATE_TRUNC で日付を月単位に丸めて集計する
日付ごとの明細を「月別」「週別」にまとめたいとき、DATE_TRUNC で日付を指定単位に丸めて(truncate)から GROUP BY します。列そのものではなく式(計算結果)でグループ化できるのがポイントです。
-- 日付を「月初」に丸めて、同じ月の行を1グループにする DATE_TRUNC('month', sale_date) -- 2024-01-05 → 2024-01-01 DATE_TRUNC('day', ts) -- 時刻を切り捨てて日単位に
DATE_TRUNC('month', …) ですべて 2024-01-01 という同じ値になります。GROUP BY はこの丸めた値でまとめるため、月別集計が成立します。GROUP BY には SELECT と同じ式を書く(または列番号 GROUP BY 1 で代用)点に注意。sales テーブルから、月別の売上件数と合計金額を求めてください。出力列は month, sales_count, total_amount。month の昇順(時系列順)で返してください。
| sale_id | sale_date | amount |
|---|---|---|
| 1 | 2024-01-05 | 1000 |
| 2 | 2024-01-20 | 1500 |
| 3 | 2024-02-03 | 2000 |
| 4 | 2024-02-15 | 500 |
| 5 | 2024-02-28 | 1000 |
| 6 | 2024-03-10 | 3000 |
| month | sales_count | total_amount |
|---|---|---|
| 2024-01-01 | 2 | 2500 |
| 2024-02-01 | 3 | 3500 |
| 2024-03-01 | 1 | 3000 |
SELECT DATE_TRUNC('month', sale_date)::date AS month, -- 月初の日付として返す COUNT(*) AS sales_count, -- 月内の件数 SUM(amount) AS total_amount -- 月内の合計金額 FROM sales GROUP BY DATE_TRUNC('month', sale_date) -- SELECTと同じ式でグループ化(GROUP BY 1 でも可) ORDER BY month; -- 月初の昇順(時系列順) /* 実行順序(SQLの論理的な評価順): 1. FROM sales → 行を読込 2. DATE_TRUNC('month', sale_date) → 各行を月初に丸める 3. GROUP BY (丸めた値) → 月でグループ化 4. COUNT(*) / SUM(amount) → 月別に集計 5. ORDER BY month → 時系列に整列 */
LEGEND
① FROM sales(6行)
FROM salessales テーブルの6行を読み込みます。sale_date は日単位でバラバラ。これを月単位に丸めて集計するのが目的です。| sale_id | sale_date | amount |
|---|---|---|
| 1 | 2024-01-05 | 1000 |
| 2 | 2024-01-20 | 1500 |
| 3 | 2024-02-03 | 2000 |
| 4 | 2024-02-15 | 500 |
| 5 | 2024-02-28 | 1000 |
| 6 | 2024-03-10 | 3000 |
DATE_TRUNC('month', sale_date) のような式の計算結果でまとめられます。これが「月別」「週別」など、元データに無い粒度の集計を可能にします。'month' を 'week' や 'day'、'quarter' に差し替えるだけです。month は GROUP BY の評価段階ではまだ確定していない場合があるため、同じ式を書くか 列番号 GROUP BY 1 を使うのが確実です(PostgreSQL はエイリアスも許容しますが、移植性を考えると式の再掲が安全)。GROUP BY sale_date では日付が1日でも違えば別グループになり、月別にならず日別にバラけます。月でまとめたいなら必ず丸めた式(DATE_TRUNC)でグループ化します。GROUP BY TO_CHAR(sale_date,'MM') のように「月番号の文字列」だけでまとめると、別の年の同月(2023年1月と2024年1月)が混ざり、さらに文字列ソートで時系列が崩れます。年も含む DATE_TRUNC(日付型)でまとめるのが安全です。DATE_TRUNC による期間グループ化が土台です。第1引数の単位を差し替えるだけで日次・週次・月次・四半期と粒度を切り替えられ、同じ集計ロジックを使い回せます。表示用にラベルを整えたいときは TO_CHAR(month, 'YYYY-MM') で文字列に整形しますが、並べ替えや結合のキーには日付型のまま使うのが、ソート崩れを防ぐコツです。区分でグループ化 — CASE 式で値をビン分割してカテゴリ別に集計する
「金額帯」「年齢層」のように連続値を区分(ビン)に分けて集計したいときは、CASE 式でカテゴリを作り、その式で GROUP BY します。生データに無い「区分」という軸を、その場で生成できます。
-- price を3つの価格帯に振り分けてグループ化キーにする CASE WHEN price < 500 THEN 'Low' WHEN price < 5000 THEN 'Mid' -- 500〜4999(上のWHEN通過=500以上が確定済み) ELSE 'High' END
price < 5000 が「500以上5000未満」を意味するのは、1つ目の < 500 を通過した行だけが到達するからです。境界条件は順序が命です。products テーブルから、価格帯ごとの商品数と平均価格を求めてください。価格帯は Low(500未満)・Mid(500以上5000未満)・High(5000以上)。出力列は price_band, product_count, avg_price(平均は整数に丸め)。avg_price の昇順で返してください。
| product_id | product | price |
|---|---|---|
| 1 | Pen | 100 |
| 2 | Notebook | 300 |
| 3 | Bag | 1500 |
| 4 | Lamp | 2500 |
| 5 | Chair | 8000 |
| 6 | Desk | 12000 |
| price_band | product_count | avg_price |
|---|---|---|
| Low | 2 | 200 |
| Mid | 2 | 2000 |
| High | 2 | 10000 |
SELECT CASE WHEN price < 500 THEN 'Low' -- 500未満 WHEN price < 5000 THEN 'Mid' -- 500以上5000未満(上のWHEN通過済みのため) ELSE 'High' -- 5000以上 END AS price_band, COUNT(*) AS product_count, -- 価格帯ごとの商品数 ROUND(AVG(price), 0) AS avg_price -- 価格帯ごとの平均価格(整数に丸め) FROM products GROUP BY CASE WHEN price < 500 THEN 'Low' WHEN price < 5000 THEN 'Mid' ELSE 'High' END -- SELECTと同じCASE式でグループ化(GROUP BY price_band でも可) ORDER BY avg_price; -- 平均価格の昇順 /* 実行順序(SQLの論理的な評価順): 1. FROM products → 行を読込 2. CASE で price_band を算出 → 価格帯を分類 3. GROUP BY (CASE式) → 価格帯でグループ化 4. COUNT(*) / ROUND(AVG(price),0) → 件数と平均を集計 5. ORDER BY avg_price → 平均価格順に整列 */
LEGEND
① FROM products(6行)
FROM productsproducts テーブルの6行を読み込みます。price は連続値。これを価格帯という区分に振り分けて集計します。| product_id | product | price |
|---|---|---|
| 1 | Pen | 100 |
| 2 | Notebook | 300 |
| 3 | Bag | 1500 |
| 4 | Lamp | 2500 |
| 5 | Chair | 8000 |
| 6 | Desk | 12000 |
WHEN price < 5000 が「500以上5000未満」を表せるのは、直前の < 500 を通った行だけがそこへ到達するからです。境界の順序設計が結果を決めます。GROUP BY price_band(エイリアス)や GROUP BY 1(列番号)でも同じ意味になり、長い CASE の重複を避けられます。WHEN price < 5000 THEN 'Mid' を先頭に置くと、本来 Low の100円・300円まで Mid に入り、Low が消滅します。狭い(小さい)条件から順に並べましょう。>= と > を混在させたり ELSE を書き忘れると、どの区分にも入らない行(未分類)や二重計上が生じます。区分は「漏れなく・重複なく(MECE)」を意識して設計します。width_bucket() 関数や、よく使う区分なら生成列(GENERATED 列)に切り出して再利用する手もあります。重複の検出 — GROUP BY と HAVING COUNT(*) > 1 で重複データを見つける
「同じメールアドレスが複数登録されていないか?」——重複の検出はグループ化の代表的な応用です。重複を調べたい列で GROUP BY し、HAVING COUNT(*) > 1 で「2件以上あるグループ」だけを残します。
-- email ごとに件数を数え、2件以上(=重複)だけを残す SELECT email, COUNT(*) AS cnt FROM users GROUP BY email HAVING COUNT(*) > 1;
GROUP BY col_a, col_b のように並べます。users テーブルから、重複して登録されている email とその件数を求めてください(2件以上のものだけ)。出力列は email, cnt。cnt の降順、同数なら email の昇順で返してください。
| user_id | |
|---|---|
| 1 | a@x.com |
| 2 | b@x.com |
| 3 | a@x.com |
| 4 | c@x.com |
| 5 | b@x.com |
| 6 | a@x.com |
| cnt | |
|---|---|
| a@x.com | 3 |
| b@x.com | 2 |
SELECT email, COUNT(*) AS cnt -- email ごとの登録件数 FROM users GROUP BY email -- 重複を調べたい列でグループ化 HAVING COUNT(*) > 1 -- 【集計後フィルタ】2件以上のグループ(=重複)だけを残す ORDER BY cnt DESC, email; -- 件数の多い順、同数は email の昇順 /* 実行順序(SQLの論理的な評価順): 1. FROM users → 行を読込 2. GROUP BY email → email でグループ化 3. COUNT(*) → 各グループの件数を集計 4. HAVING COUNT(*) > 1 → 重複グループだけ残す 5. ORDER BY cnt DESC, email → 件数の多い順で並べ替え */
LEGEND
① FROM users(6行)
FROM usersusers テーブルの6行を読み込みます。email 列を見ると a@x.com と b@x.com が複数回現れています。これを検出するのが目的です。| user_id | |
|---|---|
| 1 | a@x.com |
| 2 | b@x.com |
| 3 | a@x.com |
| 4 | c@x.com |
| 5 | b@x.com |
| 6 | a@x.com |
cnt がそのまま「何件ダブっているか」を表し、データ品質チェックに直結します。COUNT(*) はグループ化・集計の後でしか確定しないため、件数による絞り込みは HAVING の役割です。「生の行の条件=WHERE、集計値の条件=HAVING」という 使い分けが、ここでも効いてきます。GROUP BY name, birth_date HAVING COUNT(*) > 1 で見つけられます。組み合わせ単位のグループ化(Q3)と HAVING を組み合わせるだけです。WHERE COUNT(*) > 1 は構文エラーです。WHERE はグループ化の前に効くため、まだ COUNT が計算されていません。件数での絞り込みは必ず HAVING に書きます。SELECT DISTINCT email は重複を畳んで見えなくするだけで、どの値が何件ダブっているかは分かりません。重複の有無と件数を把握したいなら、GROUP BY + HAVING で「検出」する必要があります。STRING_AGG(user_id::text, ', ') を足せば「重複している実 id のリスト」まで一発で取れます。検出 → 特定 → 修正、というデータクレンジングの第一歩です。値の連結 — STRING_AGG でグループ内の値を1つの文字列にまとめる
集約の総仕上げは、数値ではなく文字列を集約する STRING_AGG です。グループ内の複数の値を、区切り文字でつないで1つの文字列にまとめます。「グループに属するメンバー一覧」を1行で表現できます。
-- グループ内の student を ', ' で連結(名前順に並べる) STRING_AGG(student, ', ' ORDER BY student) STRING_AGG(DISTINCT tag, ',') -- 重複を除いて連結 ARRAY_AGG(student) -- 文字列でなく配列で集約する姉妹関数
ORDER BY は、連結する順序を制御します(指定しないと順序は不定)。第2引数が区切り文字です。COUNT/SUM が「多くの値→1つの数値」なのに対し、STRING_AGG は「多くの値→1つの文字列」へ畳み込む集計関数です。enrollments テーブルから、コース別の受講者数と、受講者名を名前順にカンマ区切りで連結した一覧を求めてください。出力列は course, student_count, students。student_count の降順、同数なら course の昇順で返してください。
| id | course | student |
|---|---|---|
| 1 | Math | Bob |
| 2 | Math | Alice |
| 3 | Math | Carol |
| 4 | Science | Dave |
| 5 | Science | Alice |
| 6 | Art | Bob |
| course | student_count | students |
|---|---|---|
| Math | 3 | Alice, Bob, Carol |
| Science | 2 | Alice, Dave |
| Art | 1 | Bob |
SELECT course, COUNT(*) AS student_count, -- コースごとの受講者数 STRING_AGG(student, ', ' ORDER BY student) AS students -- 受講者名を名前順にカンマ区切りで連結 FROM enrollments GROUP BY course -- コース単位でグループ化 ORDER BY student_count DESC, course; -- 受講者数の多い順、同数はコース名の昇順 /* 実行順序(SQLの論理的な評価順): 1. FROM enrollments → 行を読込 2. GROUP BY course → コースでグループ化 3. 集計 → COUNT・STRING_AGG 4. ORDER BY student_count DESC, course → 人数の多い順で並べ替え */
LEGEND
① FROM enrollments(6行)
FROM enrollmentsenrollments テーブルの6行を読み込みます。1行=1人の受講登録。これをコースごとにまとめ、人数と名前一覧にします。| id | course | student |
|---|---|---|
| 1 | Math | Bob |
| 2 | Math | Alice |
| 3 | Math | Carol |
| 4 | Science | Dave |
| 5 | Science | Alice |
| 6 | Art | Bob |
STRING_AGG(student, ', ' ORDER BY student) の括弧内 ORDER BY が連結の順序を決めます。これを省くと順序は不定(実行ごとに変わりうる)。第2引数 ', ' が区切り文字です。STRING_AGG(DISTINCT student, ', ')。文字列ではなく配列で受け取りたいときは ARRAY_AGG(student) を使います。集約の出力形をニーズに合わせて選べます。STRING_AGG(student, ', ') と書くと、出力順は保証されず実行ごとに並びが変わる恐れがあります。順序が意味を持つなら、必ず集約内に ORDER BYを書きましょう。GROUP_CONCAT、Oracle は LISTAGG と関数名が異なります。STRING_AGG は PostgreSQL(および SQL Server)の構文です。別DBへ移植するときは関数名と区切り指定の書き方に注意してください。GROUP BY email HAVING COUNT(*) > 1 に STRING_AGG(user_id::text, ', ') を足せば、「重複している email と、その実レコードの id 一覧」が1クエリで取れます。グループ化で学んだ COUNT・SUM・条件付き集計・結合・期間/区分・そして文字列集約——これらを組み合わせれば、明細データから実務で必要なサマリのほとんどを表現できます。