LEFT JOINのゼロ集計の落とし穴 — COUNT(*) と COUNT(列名) の明確な違い
INNER JOIN では、社員が0人の「人事部」は結果から除外されます。LEFT JOIN(左外部結合)はこの制限を解消し、左テーブルの全行を必ず保持します。右テーブルにマッチする行がない場合はNULLを埋めた行を自動生成します。
-- INNER JOIN: 社員ゼロの部署は除外される FROM departments INNER JOIN employees ... -- 人事部: 結果に現れない -- LEFT JOIN: 社員ゼロの部署も保持、社員側はNULL FROM departments LEFT JOIN employees ... -- 人事部: emp_id=NULL で残る
COUNT(*) は「行の存在」を数えます。NULL列を持つ行も「1行として存在する」ため、社員ゼロの部署を誤って1人とカウントします。COUNT(e.emp_id) は「その列が NULL でない行数」を返し、NULL行をスキップして正しく0を返します。departments と employees を LEFT JOIN し、部門ごとの社員数(emp_count)を取得してください。社員が0人の部門も必ず含めること。
| dept_id | dept_name |
|---|---|
| 1 | 営業部 |
| 2 | 開発部 |
| 3 | 人事部 |
| emp_id | name | dept_id |
|---|---|---|
| 1 | 田中 | 1 |
| 2 | 佐藤 | 1 |
| 3 | 山田 | 2 |
| 4 | 鈴木 | 2 |
| dept_name | emp_count |
|---|---|
| 営業部 | 2 |
| 開発部 | 2 |
| 人事部 | 0 |
-- ✗ COUNT(*): NULL 行も1としてカウント → 人事部が 1(誤り) -- SELECT d.dept_name, COUNT(*) AS emp_count ... SELECT d.dept_name, COUNT(e.emp_id) AS emp_count -- NULL をスキップ → 人事部は 0(正解) FROM departments AS d LEFT JOIN employees AS e ON d.dept_id = e.dept_id GROUP BY d.dept_id, d.dept_name ORDER BY d.dept_id; /* 実行順序: 1. FROM departments AS d → departments を読み込む 2. LEFT JOIN employees AS e → 結合(1:N で展開) 3. GROUP BY dept_id, dept_name → グループ化 4. COUNT(e.emp_id) → NULLをスキップして件数集計 5. SELECT d.dept_name, emp_count → 2列を射影 6. ORDER BY d.dept_id → 並び替えて出力 */
LEGEND
① 左テーブル
FROM departments AS ddepartments テーブル(3行)を読み込みます。人事部(dept_id=3)には社員が存在しませんが、LEFT JOIN を使えば結果に含めることができます。| dept_id | dept_name |
|---|---|
| 1 | 営業部 |
| 2 | 開発部 |
| 3 | 人事部 |
LEFT JOIN → NULL生成 → COUNT(e.emp_id) でスキップ
COUNT(*) は「現在の行が存在するかどうか」だけを評価します。NULL列を持つ行も「1行として存在する」ため、LEFT JOIN で生成された NULL 行を1としてカウントします。一方 COUNT(e.emp_id) は「e.emp_id が NULL でない行数」を返すため、NULL行をスキップして正しく0を返します。この区別は SQL 最頻出の集計ミスの1つです。COUNT(COALESCE(e.emp_id, 0)) と書くと、COALESCE が NULL を 0 に変換するため COUNT は 0 も「非 NULL」として1と数えます。結果として COUNT(*) と同じく誤った1が返ります。NULL をスキップしたい場合は素直に COUNT(e.emp_id) を使いましょう。SUM には存在しません(NULL は SUM 計算でスキップ)が、COUNT には必ず意識してください。N側から1件だけを取り出す — ROW_NUMBER() を活用した最新レコード結合
「ユーザーごとに最新のログイン1件だけを取得したい」という要件を 1:N の JOIN で実現しようとすると、そのままでは login_history の行数分だけ users の行が展開されてしまいます。
Window関数(ウィンドウ関数)の ROW_NUMBER() は、グループ内の各行に連番を付与します。グループを PARTITION BY、並び順を ORDER BY で指定するため、「ユーザーごとに日付の新しい順で1番の行(rn=1)」を抽出することができます。
-- ROW_NUMBER() の構文 ROW_NUMBER() OVER ( PARTITION BY user_id -- ユーザーごとにリセット ORDER BY login_at DESC -- 新しい順に番号付け ) AS rn -- rn=1 が最新ログイン
users テーブルに対して、各ユーザーの最新ログイン日時(last_login)を結合してください。ログイン履歴がない場合は NULL が入っても構いません。
| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
| log_id | user_id | login_at |
|---|---|---|
| 1 | 1 | 2024-03-10 |
| 2 | 1 | 2024-03-15 |
| 3 | 2 | 2024-03-08 |
| 4 | 2 | 2024-03-10 |
| 5 | 3 | 2024-03-12 |
| name | last_login |
|---|---|
| 田中 | 2024-03-15 |
| 佐藤 | 2024-03-10 |
| 山田 | 2024-03-12 |
WITH ranked_logins AS ( SELECT user_id, login_at, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY login_at DESC -- 最新が rn=1 になる ) AS rn FROM login_history ) SELECT u.name, r.login_at AS last_login FROM users AS u INNER JOIN ranked_logins AS r ON u.user_id = r.user_id AND r.rn = 1 -- 最新1件のみと結合 → 1:1 になる ORDER BY u.user_id; /* 実行順序: 1. CTE ranked_logins → login_history を読み込む 2. ROW_NUMBER() OVER (...) → ユーザーごとに最新順で番号付け 3. FROM users AS u → users を読み込む 4. INNER JOIN ranked_logins (rn=1) → 最新行のみ結合 5. SELECT u.name, r.login_at → 2列を射影 6. ORDER BY u.user_id → 並び替えて出力 */
LEGEND
① CTE — 元データ
FROM login_historylogin_history テーブル(5行)を読み込みます。このままJOINすると users の行が展開されてしまいます。| log_id | user_id | login_at |
|---|---|---|
| 1 | 1 | 2024-03-10 |
| 2 | 1 | 2024-03-15 |
| 3 | 2 | 2024-03-08 |
| 4 | 2 | 2024-03-10 |
| 5 | 3 | 2024-03-12 |
login_history(N行) → rn=1に絞る → users と1:1 で結合
AND r.rn = 1 を加えることで、各ユーザーのうちrn=1の行(= 最新ログイン)だけが結合対象になります。rn=2以降の行は結合されないため、JOIN後の行数は users の行数(3行)のまま維持されます。N:1 の展開が起きない理由は「rn=1 がユーザーごとに必ず1行」という保証があるからです。RANK() は同順位に同じ番号を付与します。もし login_at の値が全く同一の行が2行あった場合、RANK では両方が rn=1 になり JOIN 後に2行が展開されます。必ず1行にしたい場合は ROW_NUMBER を使いましょう。FROM (SELECT ..., ROW_NUMBER() ... ) AS sub WHERE sub.rn = 1 と書くことも可能ですが、CTE を使うと可読性が上がります。重要なのは ROW_NUMBER を計算した後に rn=1 でフィルタする点で、Window関数は WHERE 句では直接使えないため必ずサブクエリか CTE が必要です。結合前の事前集約(Pre-aggregation) — CTEを用いた複数1:N結合のエレガントな解決
ファントラップはインラインサブクエリでも回避できますが、同じ問題を CTE(WITH句)で書くとクエリが格段に読みやすくなります。CTE は名前付きのサブクエリで、メインクエリより先に実行されます。
-- CTE の基本構文: 先に集計テーブルを定義してからJOIN WITH emp_agg AS ( SELECT dept_id, SUM(salary) AS total_salary FROM employees GROUP BY dept_id -- 1:N を 1:1 に変換 ), sales_agg AS (...) -- 複数CTEはカンマで連結 SELECT ... FROM departments JOIN emp_agg ... -- 1:1 × 1:1 の安全な結合
① 可読性: 集計ロジックを先に定義するため、メインクエリがシンプルになる。
② 再利用性: 同じCTEを複数回参照できる。
③ デバッグ容易性: CTEを単独で SELECT して途中結果を確認できる。
部門ごとに社員の給与合計(total_salary)と売上合計(total_sales)を取得してください。CTE(WITH句)を使って各1:Nテーブルを事前に集約してからJOINする正しいアプローチで解いてください。
| dept_id | dept_name |
|---|---|
| 1 | 営業部 |
| 2 | 開発部 |
| emp_id | dept_id | name | salary |
|---|---|---|---|
| 1 | 1 | 田中 | 400000 |
| 2 | 1 | 佐藤 | 350000 |
| 3 | 2 | 山田 | 500000 |
| 4 | 2 | 鈴木 | 450000 |
| sale_id | dept_id | amount |
|---|---|---|
| 1 | 1 | 800000 |
| 2 | 1 | 600000 |
| 3 | 2 | 1200000 |
| 4 | 2 | 900000 |
| dept_name | total_salary | total_sales |
|---|---|---|
| 営業部 | 750000 | 1400000 |
| 開発部 | 950000 | 2100000 |
WITH emp_agg AS ( SELECT dept_id, SUM(salary) AS total_salary FROM employees GROUP BY dept_id -- 1:N → 1:1 に変換(dept_id が一意になる) ), sales_agg AS ( SELECT dept_id, SUM(amount) AS total_sales FROM dept_sales GROUP BY dept_id -- 1:N → 1:1 に変換(dept_id が一意になる) ) SELECT d.dept_name, e.total_salary, s.total_sales FROM departments AS d INNER JOIN emp_agg AS e ON d.dept_id = e.dept_id -- 1:1 INNER JOIN sales_agg AS s ON d.dept_id = s.dept_id -- 1:1 ORDER BY d.dept_id; /* 実行順序: 1. CTE emp_agg → employees を集計(dept_id 一意) 2. CTE sales_agg → dept_sales を集計(dept_id 一意) 3. FROM departments → departments を読み込む 4. INNER JOIN emp_agg → dept_id で 1:1 結合 5. INNER JOIN sales_agg → dept_id で 1:1 結合 6. SELECT, ORDER BY → 列を射影し並び替え */
LEGEND
① CTE①元データ — employees
FROM employees(給与データ)まず、1つ目の 1:N テーブルである employees(4行)を確認します。このままでは部門ごとに複数行あるため、dept_id で集約する必要があります。| emp_id | dept_id | name | salary |
|---|---|---|---|
| 1 | 1 | 田中 | 400000 |
| 2 | 1 | 佐藤 | 350000 |
| 3 | 2 | 山田 | 500000 |
| 4 | 2 | 鈴木 | 450000 |
emp_agg = employees集計 sales_agg = dept_sales集計
SELECT DISTINCT で重複行を除去しても、SUM や AVG の集計値は膨張した誤った値のままです。DISTINCT は行の重複を除去するだけで、数値の二重カウントは修正しません。回避は「JOIN前」の集約が唯一の正解です。粒度(Granularity)不一致の罠 — 月次予算と日次売上の結合による予算膨張の回避
テーブルの粒度(Granularity)とは「1行が何を表すか」です。月次テーブルは1行が「1部門・1ヶ月」を表し、日次テーブルは1行が「1部門・1日」を表します。
粒度の異なるテーブルをそのままJOINすると、細かい粒度の行数分だけ粗い粒度の値がコピーされます。月次予算(1行/月)に3日分の売上(3行/月)をJOINすると、予算が3回複製されます。
-- ✗ 直接JOIN → monthly_budget の budget が daily_sales の行数分コピーされる FROM monthly_budget AS b JOIN daily_sales AS d ON b.dept_id = d.dept_id AND b.month = DATE_TRUNC('month', d.sale_date) -- dept_id=1: budget=500000 が 3日分複製 → SUM(budget) = 1500000(3倍!)
DATE_TRUNC('month', 日付列) + GROUP BY を使います。monthly_budget(月次予算)と daily_sales(日次売上)を使って、部門・月ごとの予算(budget)、月次売上合計(total_revenue)、予算達成率(achievement_rate: %, 小数点1桁)を取得してください。
| month | dept_id | budget |
|---|---|---|
| 2024-03-01 | 1 | 500000 |
| 2024-03-01 | 2 | 800000 |
| sale_date | dept_id | revenue |
|---|---|---|
| 2024-03-05 | 1 | 100000 |
| 2024-03-12 | 1 | 200000 |
| 2024-03-20 | 1 | 80000 |
| 2024-03-08 | 2 | 250000 |
| 2024-03-15 | 2 | 300000 |
| 2024-03-22 | 2 | 200000 |
| month | dept_id | budget | total_revenue | achievement_rate |
|---|---|---|---|---|
| 2024-03-01 | 1 | 500000 | 380000 | 76.0 |
| 2024-03-01 | 2 | 800000 | 750000 | 93.8 |
WITH monthly_sales AS ( SELECT DATE_TRUNC('month', sale_date)::DATE AS month, -- 日次を月次粒度に変換 dept_id, SUM(revenue) AS total_revenue FROM daily_sales GROUP BY DATE_TRUNC('month', sale_date)::DATE, dept_id ) SELECT b.month, b.dept_id, b.budget, ms.total_revenue, ROUND(ms.total_revenue::NUMERIC / b.budget * 100, 1) AS achievement_rate FROM monthly_budget AS b LEFT JOIN monthly_sales AS ms ON b.month = ms.month AND b.dept_id = ms.dept_id ORDER BY b.month, b.dept_id; /* 実行順序: 1. CTE monthly_sales → daily_sales を読み込む 2. DATE_TRUNC('month', ...) → 月初に丸める 3. GROUP BY month, dept_id → 月×部門でグループ化 4. SUM(revenue) → 月次売上を集計 5. FROM monthly_budget AS b → 予算を読み込む 6. LEFT JOIN monthly_sales AS ms → month+dept_id で結合 7. ROUND(...) → 達成率(%)を計算 8. SELECT, ORDER BY → 列を射影し並び替え */
LEGEND
① CTE元データ — daily_sales
FROM daily_sales(日次粒度)部門ごとに複数の日付の売上が記録された日次粒度(1行 = 1部門・1日)のテーブルです。予算テーブルの「月次粒度」に合わせるため、集約して粒度を変換する必要があります。| sale_date | dept_id | revenue |
|---|---|---|
| 2024-03-05 | 1 | 100000 |
| 2024-03-12 | 1 | 200000 |
| 2024-03-20 | 1 | 80000 |
| 2024-03-08 | 2 | 250000 |
| 2024-03-15 | 2 | 300000 |
| 2024-03-22 | 2 | 200000 |
monthly_budget(月粒度) ← CTE → monthly_sales(月粒度)
DATE_TRUNC('month', sale_date) は日付を月初(1日)に丸めます。2024-03-05、2024-03-12、2024-03-20 はすべて 2024-03-01 になります。これにより「異なる日付でも同じ月ならGROUP BY で同一グループ」として扱えます。GROUP BY のキーとして使うことで日次→月次への粒度変換が実現します。N対Nの AND検索の落とし穴 — 中間テーブルでの「AかつB」関係除算
N:N の中間テーブルで「商品AとBを両方購入したユーザー」を探す際、直感的に WHERE product = 'A' AND product = 'B' と書きたくなりますが、これは常に0件を返します。
-- ✗ AND条件は「同一行」に適用される WHERE p.product_name = 'コーヒー' AND p.product_name = 'ケーキ' -- 1行のproduct_nameは同時に2つの値を持てない → 必ず0件 -- ✓ IN で候補行を絞り、HAVING で「両方持つ」を判定する WHERE p.product_name IN ('コーヒー', 'ケーキ') -- OR条件で候補行を取得 HAVING COUNT(DISTINCT p.product_name) = 2 -- 2種類持つグループを残す
users テーブルと purchases テーブルを使って、「コーヒー」と「ケーキ」の両方を購入したユーザーを取得してください。
| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
| 4 | 鈴木 |
| purchase_id | user_id | product_name |
|---|---|---|
| 1 | 1 | コーヒー |
| 2 | 1 | ケーキ |
| 3 | 2 | コーヒー |
| 4 | 2 | サンドイッチ |
| 5 | 3 | ケーキ |
| 6 | 3 | サンドイッチ |
| 7 | 4 | コーヒー |
| 8 | 4 | ケーキ |
| user_id | name |
|---|---|
| 1 | 田中 |
| 4 | 鈴木 |
-- ✗ AND: 同一行に「コーヒー」かつ「ケーキ」は不可能 → 0件 -- WHERE p.product_name = 'コーヒー' AND p.product_name = 'ケーキ' SELECT u.user_id, u.name FROM users AS u INNER JOIN purchases AS p ON u.user_id = p.user_id WHERE p.product_name IN ('コーヒー', 'ケーキ') -- まずORで候補行を絞る GROUP BY u.user_id, u.name HAVING COUNT(DISTINCT p.product_name) = 2 -- 2種類とも持つグループのみ残す ORDER BY u.user_id; /* 実行順序: 1. FROM users AS u → users を読み込む 2. INNER JOIN purchases AS p → 結合(1:N で展開) 3. WHERE product_name IN (...) → 対象商品に絞る 4. GROUP BY u.user_id, u.name → グループ化 5. HAVING COUNT(DISTINCT product_name) → 2種類購入者のみ残す 6. SELECT u.user_id, u.name → 2列を射影 7. ORDER BY u.user_id → 並び替えて出力 */
LEGEND
① 対象データ(JOIN直後)
INNER JOIN users AS u ON u.user_id = p.user_idまず user_id で users と purchases を結合した状態です。1:Nの関係により、ユーザーが購入履歴の数だけ展開されています。全8行のこのテーブルに対して、これからWHERE句を適用します。| user_id | name | product_name |
|---|---|---|
| 1 | 田中 | コーヒー |
| 1 | 田中 | ケーキ |
| 2 | 佐藤 | コーヒー |
| 2 | 佐藤 | サンドイッチ |
| 3 | 山田 | ケーキ |
| 3 | 山田 | サンドイッチ |
| 4 | 鈴木 | コーヒー |
| 4 | 鈴木 | ケーキ |
WHERE IN → GROUP BY → HAVING COUNT(DISTINCT) = N
IN ('コーヒー', 'ケーキ')(OR条件)で対象商品の行を縦に集め、次に GROUP BY user_id でユーザーごとにまとめ、最後に HAVING COUNT(DISTINCT product_name) = 2 で「グループ内に2種類の商品がある(= 両方購入した)」ユーザーだけを残します。「縦(行)に展開してから横(グループ)で評価する」がSQLらしい集合演算の本質です。HAVING COUNT(DISTINCT product_name) = 2 の「2」は検索対象の商品数です。3種類(コーヒー・ケーキ・サンドイッチすべて)を購入したユーザーを探すなら = 3 にします。N の値は必ず「IN の中の要素数」と一致させましょう。要素数を動的に変える場合はサブクエリで = (SELECT COUNT(DISTINCT ...) FROM ...) と書けます。purchases AS a JOIN purchases AS b ON a.user_id = b.user_id WHERE a.product='コーヒー' AND b.product='ケーキ' という自己結合でも同じ結果は得られますが、検索対象が3種類・4種類に増えると結合回数が増えてクエリが爆発的に複雑になります。IN + HAVING COUNT(DISTINCT) パターンは拡張性に優れたモダンな書き方です。