GROUP BY と NULL — NULL は「ひとつのグループ」として扱われる
GROUP BY で NULL を含む列をグループ化すると、NULL 行はすべて 「NULL」という1つのグループ にまとめられます。通常の比較 (NULL = NULL → UNKNOWN) と異なり、GROUP BY は NULL 同士を「同じグループ」として扱います。
/* GROUP BY region のみ: NULLはそのまま集約 */ SELECT region, COUNT(*) FROM sales GROUP BY region; -- > 東京|2, 大阪|2, NULL|2 /* COALESCE で NULL グループに名前を付ける */ SELECT COALESCE(region, '不明'), COUNT(*) FROM sales GROUP BY COALESCE(region, '不明'); -- > 東京|2, 大阪|2, 不明|2
COUNT(DISTINCT region) は NULL を除外した ユニーク件数 を返します(東京・大阪の2種類のみ)。COUNT(DISTINCT COALESCE(region, '不明')) にすることで NULL グループも含めた3種類になります。sales テーブルから、地域別の売上合計(total_amount)と注文件数(sale_count)を計算してください。region が NULL の場合は '不明' として集計し、total_amount 降順で返してください。出力列は region, total_amount, sale_count。
| sale_id | region | amount |
|---|---|---|
| 1 | 東京 | 15000 |
| 2 | 大阪 | 8000 |
| 3 | 東京 | 12000 |
| 4 | NULL | 5000 |
| 5 | 大阪 | 9000 |
| 6 | NULL | 7000 |
| region | total_amount | sale_count |
|---|---|---|
| 東京 | 27000 | 2 |
| 大阪 | 17000 | 2 |
| 不明 | 12000 | 2 |
SELECT COALESCE(region, '不明') AS region, -- NULL を '不明' に変換 SUM(amount) AS total_amount, COUNT(*) AS sale_count FROM sales GROUP BY COALESCE(region, '不明') -- 変換後の値で集約(NULL を1グループに) ORDER BY total_amount DESC; /* 実行順序: 1. FROM: salesテーブル読込(6行) 2. GROUP BY: COALESCEで NULL を '不明' に変換し、3グループ(東京, 大阪, 不明)に分割 3. SELECT: SUM(amount) と COUNT(*) でグループごとに集計 4. ORDER BY: 合計金額の降順でソート */
LEGEND
① FROM sales(6行)
FROM salessales テーブルの6行を読み込みます。region 列に NULL(地域未設定)が2行含まれています。| sale_id | region | amount |
|---|---|---|
| 1 | 東京 | 15000 |
| 2 | 大阪 | 8000 |
| 3 | 東京 | 12000 |
| 4 | NULL | 5000 |
| 5 | 大阪 | 9000 |
| 6 | NULL | 7000 |
NULL = NULL は UNKNOWN ですが、GROUP BY は例外的に NULL 行をすべて同一グループにまとめます。GROUP BY region で NULL は「NULL グループ」になりますが、SELECT 出力では NULL のまま表示されます。COALESCE を GROUP BY 式に使うことで NULL グループに意味のあるラベルを付けられます。SELECT COALESCE(region,'不明') AS r ... GROUP BY r のように SELECT 別名を GROUP BY で参照できます。ただし、別名が入力列名と同じで曖昧な場合は入力列名が優先されます。移植性と可読性を重視するなら GROUP BY にも同じ COALESCE 式を記述するか、サブクエリで事前に変換してから GROUP BY するのが安全です。COALESCE の引数が多い場合は CTE で先変換する方法が可読性を高めます。COUNT(DISTINCT region) は NULL を除いたユニーク値の件数を返します(東京・大阪の2種類)。NULL グループも含めたユニーク件数を得るには COUNT(DISTINCT COALESCE(region, '不明')) にする必要があります。GROUP BY と COUNT(DISTINCT) で NULL の扱いが一貫しているかを確認してください。SELECT COALESCE(region,'不明') AS region ... GROUP BY region のように別名を元列と同じ名前にすると、PostgreSQL では GROUP BY の region が入力列として解釈され、意図した SELECT 別名を参照しないことがあります。このデータでは結果が一致しても、正規化ルールが増えるとグループと表示ラベルがずれる原因になります。region_label のような別名を使うか、GROUP BY に同じ COALESCE 式を明記してください。HAVING COALESCE(region,'不明') IS NULL は一致しません('不明' は NULL ではない文字列)。COALESCE 変換後は HAVING COALESCE(region,'不明') = '不明' を使い、変換前の元列で絞る場合は HAVING region IS NULL を使ってください。GROUP BY COALESCE(region,'不明') で「不明」グループを明示的に計上し、全体に占める割合(NULL 率)を定期モニタリングすることがデータ品質管理の基本です。dbt では NULL グループを含む集計ビューを作成し、ダッシュボードで NULL 率のトレンドを監視する設計が推奨されます。NULL が急増した場合はデータパイプラインの上流(ETL・フォーム入力)に問題がある可能性を示します。ウィンドウ関数と NULL — LAG の「境界 NULL」と「データ NULL」を区別する
ウィンドウ関数 LAG(col) は前の行の値を参照しますが、返される NULL には 2種類 あり、意味が異なります。
/* ① 境界 NULL: 先頭行に「前行」が存在しないため NULL */ LAG(amount) OVER (ORDER BY dt) /* ② データ NULL: 前行の値自体が NULL (欠損) のため NULL */ -- デフォルト値引数は ①境界NULL のみ回避し、②データNULL には効かない LAG(amount, 1, 0) OVER (ORDER BY dt)
amount / prev_amount - 1 において amount か prev_amount のどちらかが NULL であれば成長率 growth_rate も NULL になります。これは欠損データ間の成長率が「計算不能」であることを正確に表現しています。daily_sales テーブルを使い、各日の前日比成長率(growth_rate)を計算してください。CTE で LAG を使って prev_amount を求め、growth_rate = (amount / prev_amount − 1) × 100 を計算してください。amount または prev_amount が NULL の日は growth_rate も NULL になります。出力列は dt, amount, prev_amount, growth_rate、dt 昇順、growth_rate は小数第1位まで丸めてください。
| dt | amount |
|---|---|
| 2024-01-01 | 10000 |
| 2024-01-02 | 12000 |
| 2024-01-03 | NULL |
| 2024-01-04 | 9000 |
| 2024-01-05 | 11000 |
| 2024-01-06 | NULL |
| 2024-01-07 | 13000 |
| dt | amount | prev_amount | growth_rate |
|---|---|---|---|
| 2024-01-01 | 10000 | NULL | NULL |
| 2024-01-02 | 12000 | 10000 | 20.0 |
| 2024-01-03 | NULL | 12000 | NULL |
| 2024-01-04 | 9000 | NULL | NULL |
| 2024-01-05 | 11000 | 9000 | 22.2 |
| 2024-01-06 | NULL | 11000 | NULL |
| 2024-01-07 | 13000 | NULL | NULL |
WITH lagged AS ( SELECT dt, amount, LAG(amount) OVER (ORDER BY dt) AS prev_amount -- 前日の値(先頭行は NULL) FROM daily_sales ) SELECT dt, amount, prev_amount, ROUND( (amount::numeric / NULLIF(prev_amount, 0) - 1) * 100, -- 0 除算を回避(0なら NULL) 1 ) AS growth_rate FROM lagged ORDER BY dt; /* 実行順序: 1. CTE (lagged): daily_sales を読み込み、LAG関数で前日の amount を取得 (先頭行は「境界NULL」、前日が欠損している行は「データNULL」となる) 2. SELECT: 取得した prev_amount を用いて前日比を計算 (amount か prev_amount のいずれかが NULL の場合、計算結果も NULL に伝搬する) 3. ORDER BY: 日付の昇順でソート */
LEGEND
① FROM daily_sales(7行)
FROM daily_salesdaily_sales テーブルの7行を読み込みます。amount 列に NULL(欠損: 2024-01-03, 2024-01-06)が含まれています。LAG がこの NULL をどのように伝搬させるかがポイントです。| dt | amount |
|---|---|
| 2024-01-01 | 10000 |
| 2024-01-02 | 12000 |
| 2024-01-03 | NULL |
| 2024-01-04 | 9000 |
| 2024-01-05 | 11000 |
| 2024-01-06 | NULL |
| 2024-01-07 | 13000 |
LAG(col, 1, 0) の第3引数(デフォルト値)で回避できます。②「データ NULL」は前行の値自体が NULL(欠損データ)から発生します。デフォルト値引数は①にのみ効き、②には効かない点が重要です。分析でこの2種類を区別する場合は、LAG の後に追加のフラグ列で判別します。NULL + 1、NULL * 100、NULL / 5 はすべて NULL です。計算式の中に1つでも NULL があれば式全体が NULL になるため、欠損を補完する場合は COALESCE を計算式の中に入れる前に適用します。これが「NULL が静かに計算を壊す」本質的なメカニズムです。ASC で NULLS LAST(NULL を最後に)、DESC で NULLS FIRST(NULL を最初に)です。ORDER BY col DESC NULLS LAST と明示することで DBMS 依存のデフォルトに頼らず意図した順序を保証できます。ウィンドウ関数の OVER (ORDER BY dt NULLS FIRST) でも同様に機能します。LAG(amount, 1, 0) OVER (ORDER BY dt) は先頭行のみ 0 を返します。2024-01-04 のように前行(2024-01-03)の amount が NULL の場合、デフォルト値は使われず NULL がそのまま返ります。デフォルト値引数は「前行が存在しない」場合にのみ使われ、「前行の値が NULL」の場合には使われません。COALESCE(growth_rate, 0) は growth_rate が NULL のとき 0% に置換しますが「欠損日の成長率は 0%」という誤ったビジネス解釈になります。NULL は「計算不能・欠損」を正確に表しているため、安易に 0 に置換せず NULL のまま保持してダッシュボード側でラベル表示を工夫するのが正しい設計です。LAG(amount) IGNORE NULLS OVER (ORDER BY dt) を使うと NULL をスキップして直前の非 NULL 値を参照できます。BigQuery の LAG には IGNORE NULLS 構文がないため、LAST_VALUE(amount IGNORE NULLS) OVER (ORDER BY dt ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) などで前方補完を実装します。PostgreSQL では IGNORE NULLS をサポートしていないため、CTE で欠損行を除外してから LAG を使うか、累積グループと MAX などで前方補完を実装する方法が使われます。欠損日をどう扱うか(スキップ vs NULL 保持)は KPI 定義の問題であり、データ仕様書に明記することが重要です。NOT IN サブクエリの NULL トラップ — NULL が1件でも全行が「静かに消える」
NOT IN のリストに NULL が1件でも含まれると、全行が UNKNOWN → WHERE に除外されて0件になる致命的な罠があります。
/* NOT IN に NULL が含まれると全行が UNKNOWN となり除外される */ dept_id NOT IN (10, NULL) -- dept_id=20 の場合: IN評価が UNKNOWN → NOT IN も UNKNOWN → 除外(バグ) /* NOT EXISTS は NULL に影響されない安全なアンチジョイン */ WHERE NOT EXISTS (SELECT 1 FROM t WHERE t.dept_id = c.dept_id) -- 一致行がない場合(NULL比較含む)は EXISTS=FALSE → NOT EXISTS=TRUE(通過)
NOT IN (SELECT col FROM ...) でサブクエリの列に NULL が1件でもあると全行除外されます。NOT EXISTS が NULL-safe な推奨代替手段です。candidates テーブルから、blocked_dept_ids に含まれる dept_id を持つ候補者を除外して取得してください。ただし blocked_dept_ids テーブルの dept_id には NULL が含まれていることに注意してください。また、候補者の dept_id が NULL の場合(佐藤)は「ブロック対象であると確認できない」ため、除外せず結果に含めてください。出力列は candidate_id, name, dept_id、candidate_id 昇順。
| candidate_id | name | dept_id |
|---|---|---|
| 1 | 田中 | 10 |
| 2 | 鈴木 | 20 |
| 3 | 佐藤 | NULL |
| 4 | 伊藤 | 10 |
| 5 | 山田 | 30 |
| dept_id |
|---|
| 10 |
| NULL |
| candidate_id | name | dept_id |
|---|---|---|
| 2 | 鈴木 | 20 |
| 3 | 佐藤 | NULL |
| 5 | 山田 | 30 |
SELECT candidate_id, name, dept_id FROM candidates c WHERE NOT EXISTS ( -- 一致行が無い候補のみ通過 SELECT 1 FROM blocked_dept_ids b WHERE b.dept_id = c.dept_id -- 相関比較(NULL dept は不一致=通過) ) ORDER BY candidate_id; /* 1. 相関サブクエリ(NOT EXISTS)でブロック対象を判定 2. dept_id=10 は EXISTS=TRUE となり除外される 3. dept_id=NULL(佐藤)や20,30は EXISTS=FALSE となり通過する */
LEGEND
① FROM candidates(5行)+ blocked_dept_ids: (10, NULL)
FROM candidates / blocked_dept_ids には NULL が混在candidates の5行を確認します。blocked_dept_ids の dept_id は (10, NULL) の2件です。この NULL が NOT IN の罠になります。| candidate_id | name | dept_id |
|---|---|---|
| 1 | 田中 | 10 |
| 2 | 鈴木 | 20 |
| 3 | 佐藤 | NULL |
| 4 | 伊藤 | 10 |
| 5 | 山田 | 30 |
x NOT IN (..., NULL, ...) は NOT (x = v1 OR x = v2 OR x = NULL) に展開され、x = NULL が UNKNOWN になります。OR チェーンで UNKNOWN があると IN = UNKNOWN → NOT IN = UNKNOWN → WHERE が除外します。「NULL に等しくない」ことを証明できないため全行が除外される3値論理の帰結です。NOT IN (SELECT col FROM t WHERE col IS NOT NULL) で NULL を除外してから NOT IN を使う。実務では①または②が推奨されます。AND dept_id IS NOT NULL を WHERE に追加してください。NULL の候補者を通過させるか除外するかはビジネス要件次第であり、実装前に仕様を確認することが重要です。FILTER 句と CASE WHEN 集計 — NULL を「条件付き」で集計する実務パターン
集約関数に FILTER (WHERE ...) を付けると、条件一致行だけを集計できます。CASE WHEN との等価性と、集計列に NULL が含まれる場合の扱いの違いを理解することが重要です。
/* FILTER 句: 条件一致行のみを集計 */ COUNT(*) FILTER (WHERE event_type = 'click') -- COUNT(*) は行を数えるため、他列が NULL でも 1 としてカウントされる /* 対象列の指定と NULL のスキップ */ AVG(duration_sec) FILTER (WHERE event_type = 'purchase') -- duration_sec が NULL の行は計算から除外される
COUNT は条件に一致する行が 0 件のとき 0 を返します。一方 AVG や SUM は 0 件のとき NULL を返します。また、AVG(col) は計算対象の col がすべて NULL の場合も NULL を返します。app_events テーブルを使い、ユーザーごとにイベントタイプ別の件数(view_count, click_count, purchase_count)と購入イベントの平均所要時間(avg_purchase_sec)を計算してください。出力列は user_id, view_count, click_count, purchase_count, avg_purchase_sec、user_id 昇順、avg_purchase_sec は小数第1位まで丸めてください。
| event_id | user_id | event_type | duration_sec |
|---|---|---|---|
| 1 | U1 | view | 15 |
| 2 | U1 | click | NULL |
| 3 | U1 | purchase | 45 |
| 4 | U2 | view | 20 |
| 5 | U2 | click | NULL |
| 6 | U2 | click | NULL |
| 7 | U3 | purchase | 60 |
| 8 | U3 | view | 10 |
| user_id | view_count | click_count | purchase_count | avg_purchase_sec |
|---|---|---|---|---|
| U1 | 1 | 1 | 1 | 45.0 |
| U2 | 1 | 2 | 0 | NULL |
| U3 | 1 | 0 | 1 | 60.0 |
SELECT user_id, COUNT(*) FILTER (WHERE event_type = 'view') AS view_count, -- 条件に合う行だけ集計 COUNT(*) FILTER (WHERE event_type = 'click') AS click_count, COUNT(*) FILTER (WHERE event_type = 'purchase') AS purchase_count, ROUND( AVG(duration_sec) FILTER (WHERE event_type = 'purchase'), -- purchase 行のみ平均 1 ) AS avg_purchase_sec FROM app_events GROUP BY user_id ORDER BY user_id; /* 実行順序: 1. FROM: app_events を読込 2. GROUP BY: user_id 単位で分割 3. SELECT (FILTER集計) 4. ORDER BY: ユーザーIDでソート */
LEGEND
① FROM app_events(8行)
FROM app_eventsapp_events テーブルの8行を読み込みます。click イベントは duration_sec がすべて NULL になっています。| event_id | user_id | event_type | duration_sec |
|---|---|---|---|
| 1 | U1 | view | 15 |
| 2 | U1 | click | NULL |
| 3 | U1 | purchase | 45 |
| 4 | U2 | view | 20 |
| 5 | U2 | click | NULL |
| 6 | U2 | click | NULL |
| 7 | U3 | purchase | 60 |
| 8 | U3 | view | 10 |
COUNT(*) FILTER (WHERE cond) は cond=TRUE の行のみカウントします。CASE WHEN との等価関係は COUNT(CASE WHEN cond THEN 1 END) ですが、FILTER の方が可読性が高く、集計計画もシンプルです。1つの GROUP BY で複数の条件別集計を書くときに特に有効です。CASE WHEN cond THEN 1 END の ELSE は省略すると ELSE NULL が暗黙補完されます。COUNT は NULL をスキップするため、条件一致行だけがカウントされます。ELSE 0 を追加すると全行が非 NULL になり COUNT(*) と同じ結果(全行カウント)になる罠があります。SUM でも同様で SUM(CASE WHEN cond THEN amount ELSE 0 END) は全件の amount 合計になります。AVG(col) FILTER (WHERE cond) でグループ内に条件一致行が0件のとき、AVG は NULL を返します。COUNT は0件で 0 を返すのに対し、AVG/SUM は0件で NULL を返すという違いがあります。U2 の avg_purchase_sec が NULL になるのはこのためです。0に置換したい場合は COALESCE(AVG(...) FILTER (...), 0) を使いますが、「購入なし = 平均0秒」が正しい解釈かビジネス要件を確認してください。COUNT(CASE WHEN event_type='view' THEN 1 ELSE 0 END) は ELSE 0 で全行が非 NULL になるため、COUNT の結果がグループ内の全行数(= COUNT(*))になります。条件別カウントには ELSE を省略するか、明示的に ELSE NULL と書いてください。SUM では ELSE 0 が「0円として集計に含める」意図になるため正しい場合もありますが、COUNT では必ず ELSE を省略します。複数条件 LEFT JOIN — ON 句フィルタと WHERE 句フィルタで結果が変わる
LEFT JOIN で結合した後のデータを集計する際、絞り込み条件を書く場所と集計方法によって結果が大きく変わります。
/* 1. ON 句 vs WHERE 句の違い */ LEFT JOIN orders o ON m.id = o.member_id AND o.status = 'completed' -- ✓ 一致しない会員も残る(正しい LEFT JOIN の挙動) LEFT JOIN orders o ON m.id = o.member_id WHERE o.status = 'completed' -- × 注文がない会員が消えてしまう(実質 INNER JOIN になる) /* 2. 複数条件の集計 (CASE WHEN) */ SUM(CASE WHEN o.status = 'completed' THEN o.amount END) COUNT(CASE WHEN o.status = 'cancelled' THEN 1 END)
SUM は NULL を返しますが COUNT は 0 を返します。SUM には COALESCE で NULL → 0 への変換が必要ですが、COUNT は不要です。members テーブルと orders テーブルを使い、会員ごとの完了注文合計金額(completed_amount)とキャンセル注文件数(cancelled_count)を計算してください。注文がない会員も 0 として出力してください。出力列は member_id, name, completed_amount, cancelled_count、member_id 昇順。
| member_id | name |
|---|---|
| 1 | 田中 |
| 2 | 鈴木 |
| 3 | 佐藤 |
| 4 | 伊藤 |
| 5 | 山田 |
| order_id | member_id | status | amount |
|---|---|---|---|
| 1 | 1 | completed | 5000 |
| 2 | 1 | cancelled | 2000 |
| 3 | 2 | completed | 8000 |
| 4 | 2 | completed | 3000 |
| 5 | 3 | pending | 1500 |
| 6 | 3 | cancelled | 1000 |
| 7 | 4 | completed | 6000 |
| 8 | 1 | pending | 4000 |
| member_id | name | completed_amount | cancelled_count |
|---|---|---|---|
| 1 | 田中 | 5000 | 1 |
| 2 | 鈴木 | 11000 | 0 |
| 3 | 佐藤 | 0 | 1 |
| 4 | 伊藤 | 6000 | 0 |
| 5 | 山田 | 0 | 0 |
SELECT m.member_id, m.name, COALESCE( SUM(CASE WHEN o.status = 'completed' THEN o.amount END), -- 完了注文のみ合計 0 ) AS completed_amount, COALESCE( COUNT(CASE WHEN o.status = 'cancelled' THEN 1 END), -- キャンセル注文を計数 0 ) AS cancelled_count FROM members m LEFT JOIN orders o ON m.member_id = o.member_id -- 未注文の会員も保持 GROUP BY m.member_id, m.name ORDER BY m.member_id; /* 実行順序: 1. FROM & LEFT JOIN → members と orders を結合(未注文も保持) 2. GROUP BY → 会員ごとにグループ化 3. SELECT (CASE WHEN集計) → 条件付き集計(COALESCEで0補正) 4. ORDER BY → 会員IDでソート */
LEGEND
① FROM members
FROM members mベースとなる members テーブルの5行です。注文がない山田(member_id=5)もこの時点では存在します。| member_id | name |
|---|---|
| 1 | 田中 |
| 2 | 鈴木 |
| 3 | 佐藤 |
| 4 | 伊藤 |
| 5 | 山田 |
NULL = 'completed' → UNKNOWN → WHERE が除外し、実質 INNER JOIN になります。右テーブルへの絞り込みは ON 句に書くか、CASE WHEN を使うのが安全です。SUM(CASE WHEN status='completed' THEN amount END) パターンで、1回の LEFT JOIN から複数の条件集計を安全に行えます。CASE の ELSE 省略 = ELSE NULL なので、SUM/COUNT は NULL をスキップして条件一致行のみを集計します。JOIN を複数回実行するより効率的で、NULL の扱いも一貫します。SUM は NULL を返しますが COUNT は 0 を返します。SUM には COALESCE(SUM(...), 0) が必要ですが、COUNT には不要です(付けても問題なし)。この違いを意識することで不必要な COALESCE を省略でき、逆に必要な COALESCE の付け忘れを防げます。LEFT JOIN orders o ON ... WHERE o.status = 'completed' と書くと o.status=NULL の行(注文なし・pending・cancelled の会員)が除外されます。注文なし会員(山田)や completed を持たない会員(佐藤)が消えるため、「全会員の集計」にならない重大なバグです。CASE WHEN 集計か、ON 句に AND o.status='completed' を追加してフィルタしてください。intermediate モデルで LEFT JOIN + CASE WHEN 集計を行い、mart モデルで COALESCE NULL→0 の変換を行う分離設計が一般的です。COALESCE を一箇所に集約することで「NULL を0として扱う」ロジックの管理が容易になります。また PostgreSQL の crosstab 関数や BigQuery の PIVOT 句を使うと動的なピボットも可能ですが、静的な CASE WHEN の方が可読性と移植性が高いため通常は推奨されます。