SQL NULL — IS NULL・COALESCE・LEFT JOINの基礎

基礎NULL基礎IS NULL / IS NOT NULLCOALESCE / NULLIFCOUNT と NULLLEFT JOIN アンチジョインPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

IS NULL / IS NOT NULL — NULL との等値比較は「常に UNKNOWN」になる

IS NULLIS NOT NULLNULL判定3値論理
前提知識

SQL には TRUE / FALSE / UNKNOWN の3値論理があります。NULL との全ての比較演算(=、!=、<、>)は UNKNOWN を返す ため、WHERE deleted_at = NULL は期待どおりに動きません。

-- ✗ 動かない(全行除外される)
WHERE deleted_at = NULL     -- NULL = NULL → UNKNOWN → WHERE が除外

-- ✓ 正しい
WHERE deleted_at IS NULL       -- NULL IS NULL → TRUE → 通過
WHERE deleted_at IS NOT NULL   -- NULL IS NOT NULL → FALSE → 除外
WHERE 句の通過条件:WHERE 句は TRUE の行だけを通過させます。UNKNOWN(NULL との等値比較の結果)は FALSE 相当として扱われ行は除外されます。IS NULLIS NOT NULL だけが NULL に対して確実に TRUE または FALSE を返せる唯一の演算子です。
問題

users テーブルから、deleted_at が NULL のユーザーは 'active'、NULL でないユーザーは 'deleted' の status を付与してください。出力列は id, name, status、id 昇順で返してください。

使用テーブル
► users(5行)
idnamedeleted_at
1田中NULL
2鈴木2024-01-10
3佐藤NULL
4伊藤2024-01-15
5山田NULL
期待出力
idnamestatus
1田中active
2鈴木deleted
3佐藤active
4伊藤deleted
5山田active
模範解答コード
SELECT
  id,
  name,
  CASE
    WHEN deleted_at IS NULL THEN 'active'  -- NULL → アクティブ
    ELSE                       'deleted'  -- 非 NULL → 退会済み
  END AS status
FROM  users
ORDER BY id;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM users                    → 行を読込
  2. CASE WHEN deleted_at IS NULL  → IS NULL で正しく判定
  3. SELECT id, name, status       → 3列を出力
  4. ORDER BY id                   → id 昇順
  */
解説(テーブル変化・ポイント)
SELECT id, name, CASE WHEN deleted_at IS NULL THEN 'active' ELSE 'deleted' END AS status FROM users ORDER BY id;
LEGEND
データ取得・読込対象
① FROM users(5行)
FROM usersusers テーブルの5行を読み込みます。deleted_at 列に NULL(退会日未設定 = アクティブ)と日付値(退会日設定 = 退会済み)が混在しています。
1 / 3
idnamedeleted_at
1田中NULL
2鈴木2024-01-10
3佐藤NULL
4伊藤2024-01-15
5山田NULL
5行読込
学習ポイント
SQL の3値論理 — NULL との比較は常に UNKNOWN:通常の演算子(=、!=、<、>)で NULL を比較すると TRUE でも FALSE でもなく UNKNOWN を返します。WHERE 句は TRUE の行のみを通過させるため、UNKNOWN は実質 FALSE 扱いになります。NULL = NULL は直感に反して TRUE にならない点が最大の落とし穴です。
IS NULL / IS NOT NULL だけが NULL を正しく検出できる:IS NULL は対象が NULL のとき TRUE を返し、IS NOT NULL は非 NULL のとき TRUE を返します。この2つの演算子だけが NULL に対して確実に TRUE または FALSE を返せます。NULL チェックには必ず IS NULL / IS NOT NULL を使うのが鉄則です。
NULL は「値が存在しない・不明」を意味する(0 や空文字と別物):NULL は 0 でも空文字 ('') でもありません。deleted_at IS NULL は「退会日が未設定」= アクティブユーザー を意味します。ソフトデリートパターンでは deleted_at が NULL かどうかで論理削除を管理し、WHERE deleted_at IS NULL を付け忘れると退会済みユーザーが分析に混入するバグになります。
アンチパターン
WHERE col = NULL / WHERE col != NULL で全行除外:等値比較・不等値比較どちらも NULL との組み合わせでは UNKNOWN を返すため、WHERE 句を通過する行が0件になります。「NULL でない行を探す」目的で col != NULL と書くのも同じ罠です。必ず IS NULL / IS NOT NULL を使ってください。
AND/OR と NULL が絡む複合条件:WHERE score > 0 AND score != NULL のように書くと score != NULL が UNKNOWN になり、AND の結果も UNKNOWN → 全行除外になります。NULL チェックは IS NULL を単独で行い、それを AND/OR で他の条件と組み合わせる形にしてください。また NOT (col IS NULL)col IS NOT NULL と同義です。
実務コラム:ソフトデリートと論理削除管理
ソフトデリートパターンは現代 Web アプリで広く使われる設計で、DELETE を実行する代わりに deleted_at が NULL かどうかで「削除済み」を管理します。クエリに WHERE deleted_at IS NULL を書き忘れると退会済みユーザーがレポートに混入します。dbt では Macro やビューで WHERE deleted_at IS NULL を一箇所に集約し、下流クエリが NULL チェックを意識しなくてよい設計にすることがベストプラクティスです。同様に is_active フラグ(BOOLEAN)を別途設けてアプリ側で管理し、クエリ側のミスを防ぐ設計も有効です。
QUESTION 2

COUNT(*) vs COUNT(col) — NULL は集計から「静かに消える」

COUNT(*)COUNT(col)NULL と集計関数利用率計算
前提知識

全ての標準 SQL 集約関数(COUNT・SUM・AVG・MIN・MAX)は NULL を無視します。唯一の例外が COUNT(*) で、NULL の有無に関係なく全行を数えます。

COUNT(*)            -- 全行を数える(NULL を含む)
COUNT(coupon_code)  -- NULL を除いて数える(非 NULL 件数のみ)
SUM(amount)         -- NULL 行を除いて合計
AVG(score)          -- NULL 行を除いて平均(分母も NULL 行を引く)
COUNT(col) と COUNT(*) の本質的な違い:COUNT(col) は式の値が NULL のとき、その行をカウントから除外します。COUNT(*) は行そのものを数えるため NULL の影響を受けません。この差が利用率・回答率・完了率など「全体に占める割合」の計算で重大な意味を持ちます
問題

orders テーブルから、全注文数(total_orders)・クーポン利用注文数(coupon_orders)・クーポン利用率(%)(coupon_rate)を1行で返してください。coupon_rate は小数第1位まで丸めてください。

使用テーブル
► orders(6行)
order_iduser_idamountcoupon_code
11013000SALE10
21025000NULL
31012000SALE10
41038000NULL
51041500NEW20
61024000NULL
期待出力
total_orderscoupon_orderscoupon_rate
6350.0
模範解答コード
SELECT
  COUNT(*)                         AS total_orders,  -- 全6行(NULL 含む)
  COUNT(coupon_code)               AS coupon_orders, -- NULL 除外 → 3行
  ROUND(
    COUNT(coupon_code) * 100.0    -- 100.0 で numeric 変換(整数除算を回避)
      / COUNT(*),
    1
  )                                AS coupon_rate
FROM  orders;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM orders                            → 行を読込
  2. COUNT(*)                               → 全行をカウント
  3. COUNT(coupon_code)                     → NULL 行を除外してカウント
  4. COUNT(coupon_code) * 100.0 / COUNT(*)  → NULL率を計算
  5. ROUND(..., 1)                          → 小数第1位で丸め
  6. SELECT                                 → 3列を1行で出力
  */
解説(テーブル変化・ポイント)
SELECT COUNT(*) AS total_orders, COUNT(coupon_code) AS coupon_orders, ROUND( COUNT(coupon_code) * 100.0 / COUNT(*), 1 ) AS coupon_rate FROM orders;
LEGEND
データ取得・読込対象
① FROM orders(6行)
FROM ordersorders テーブルの6行を読み込みます。coupon_code 列に NULL(クーポン未使用:order_id=2,4,6)と値(クーポン使用:order_id=1,3,5)が混在しています。
1 / 4
order_iduser_idamountcoupon_code
11013000SALE10
21025000NULL
31012000SALE10
41038000NULL
51041500NEW20
61024000NULL
6行読込
学習ポイント
COUNT(col) は NULL をカウントしない:COUNT(expr) は式の値が NULL のとき、その行をカウントから除外します。COUNT(*) は行そのものを数えるため NULL の影響を受けません。この差が利用率・回答率・完了率などを正確に計算するための鍵になります。
SUM / AVG / MAX / MIN も同様に NULL を無視する:全ての標準 SQL 集約関数は NULL を無視します。「SUM(score) で NULL が 0 として加算される」は誤解です。NULL は集約計算から完全に除外されます。NULL が「未入力」を意味する列の集計では、意図せずサンプルサイズが変わることに注意してください。
COUNT(*) − COUNT(col) で NULL 件数を求められる:全行数から非 NULL 行数を引くと NULL 件数が計算できます。COUNT(*) - COUNT(coupon_code) = 3 がクーポン未使用注文数です。これは COUNT(CASE WHEN coupon_code IS NULL THEN 1 END) と等価ですが、より簡潔に書けます。
アンチパターン
利用率の分母に COUNT(coupon_code) を使う:COUNT(coupon_code) * 100.0 / COUNT(coupon_code) とすると常に 100% になります。クーポン利用率は「全注文に対する割合」なので、分母は必ず COUNT(全体)= COUNT(*) にしてください。何が母数(分母)になるべきかを明確にすることが集計設計の基本です。
整数同士の割り算で 0 になる:COUNT(coupon_code) / COUNT(*) = 3 / 6 = 0(PostgreSQL の整数除算)。必ず * 100.0::numeric でキャストしてから除算してください。リテラル 100.0 を掛けることで自動的に numeric 型に昇格し、小数の計算が可能になります。
実務コラム:ユーザー行動率の計算と NULL の扱い
ECサイトやアプリ分析では「クリック率」「購入率」「完了率」など分子と分母の定義が重要です。分母に COUNT(*) を使うか COUNT(col) を使うかで集計値が大きく変わります。NULL が「未実施」を意味するのか「データなし(欠損)」を意味するのかをデータ定義書で明確化し、集計の分母を統一することがデータ品質管理の基本です。特にダッシュボードの KPI 定義では「誰でも同じ計算で同じ数値を得られるか」を基準に SQL を設計してください。
QUESTION 3

COALESCE / NULLIF — NULL を安全に「置換」し「生成」する2つの武器

COALESCENULLIFNULL 置換ゼロ除算防止
前提知識

COALESCE と NULLIF は NULL を安全に扱うための関数です。どちらも実務で頻出する NULL 対策の定番パターンです。

-- COALESCE: 左から順に評価し、最初の非 NULL 値を返す
COALESCE(discount_price, price)
-- discount_price = NULL → price を使う(フォールバック)
-- discount_price = 800  → 800 を返す(price は無視)

-- NULLIF: a = b のとき NULL を返す(それ以外は a を返す)
NULLIF(stock, 0)
-- stock = 0  → NULL(ゼロ除算エラーを回避)
-- stock = 50 → 50(そのまま返す)
NULLIF でゼロ除算を防ぐ黄金パターン:x / NULLIF(y, 0) は y = 0 のとき NULL を返すため、Division by zero エラーを回避できます。NULLIF は「特定の値を NULL に変換して計算を安全にする」逆転の発想の関数です。
問題

products テーブルから、実売価格(discount_price があれば discount_price、なければ price → effective_price)在庫回転率(sold ÷ stock → turnover_rate、stock が 0 のとき NULL)を計算してください。出力列は product_id, name, effective_price, turnover_rate、product_id 昇順で返してください。turnover_rate は小数第2位まで丸めてください。

使用テーブル
► products(5行)
product_idnamepricediscount_pricestocksold
1A10008005020
2B2000NULL3030
3C50040000
4D3000250010040
5E1500NULL05
期待出力
product_idnameeffective_priceturnover_rate
1A8000.40
2B20001.00
3C400NULL
4D25000.40
5E1500NULL
模範解答コード
SELECT
  product_id,
  name,
  COALESCE(discount_price, price)   AS effective_price,  -- 最初の非 NULL を返す
  ROUND(
    sold::numeric / NULLIF(stock, 0),  -- stock=0 → NULL(ゼロ除算回避)
    2
  )                                   AS turnover_rate
FROM  products
ORDER BY product_id;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM products                      → 行を読込
  2. COALESCE(discount_price, price)    → 最初の非 NULL 値を返す
  3. NULLIF(stock, 0)                   → stock=0 を NULL に変換
  4. sold::numeric / NULLIF(stock, 0)   → 回転率を計算(0除算回避)
  5. ROUND(..., 2) / SELECT / ORDER BY  → 丸めて出力
  */
解説(テーブル変化・ポイント)
SELECT product_id, name, COALESCE(discount_price, price) AS effective_price, ROUND( sold::numeric / NULLIF(stock, 0), 2 ) AS turnover_rate FROM products ORDER BY product_id;
LEGEND
データ取得・読込対象
① FROM products(5行)
FROM productsproducts テーブルの5行を読み込みます。discount_price 列に NULL(セールなし)と値(セール価格あり)が混在。stock=0 の行(id=3,5)がゼロ除算の罠になります。
1 / 4
product_idnamepricediscount_pricestocksold
1A10008005020
2B2000NULL3030
3C50040000
4D3000250010040
5E1500NULL05
5行読込
学習ポイント
COALESCE は「最初の非 NULL を返す」フォールバック関数:引数を左から順に評価し、最初の非 NULL 値を返します。COALESCE(a, b, c) のように 引数を複数チェーンして多段フォールバックを構成できます。全引数が NULL の場合のみ NULL を返します。NULL 許容列のデフォルト値設定に最も広く使われる関数です。
NULLIF は「特定の値を NULL に変換する」逆転の発想:NULLIF(a, b) は a = b のとき NULL を返し、それ以外は a をそのまま返します。ゼロ除算防止の x / NULLIF(y, 0) パターンだけでなく、空文字を NULL に統一する NULLIF(col, '') や、番兵値(-1 = 未設定)を NULL に変換する用途にも使えます。
COALESCE の引数は型を一致させる必要がある:COALESCE(price, '価格なし') のように INT と TEXT を混在させるとキャストエラーになります。数値列のデフォルトは 0(同じ数値型)、文字列列のデフォルトは ''(空文字)にするか、明示的に ::text でキャストして型を統一してください。
アンチパターン
MySQL の IFNULL / SQL Server の ISNULL を PostgreSQL で使う:IFNULL(a, b)ISNULL(a, b) は PostgreSQL では動作しません(関数が存在しない)。標準 SQL の COALESCE を使うことで全ての DBMS に移植できます。チームのコードベースを統一するためにも COALESCE を使う習慣をつけてください。
NULL をデフォルト値で埋めてから COUNT する:COUNT(COALESCE(score, 0)) はすべての行の score が非 NULL になるため、COUNT(score) と異なる結果(= COUNT(*))になります。「NULL も 0 として集計に含める」意図がある場合のみ COALESCE を使い、「NULL 行を除いたカウント」なら COUNT(score) をそのまま使ってください。
実務コラム:NULL のデフォルト値戦略と「早期 NULL 解消」
データ分析パイプラインでは NULL に対するデフォルト値の設計が重要です。「欠損値を 0 で埋める」のか「NULL のまま保持して COUNT(*) を分母にする」のかは、ビジネス上の意味が異なります。dbt では COALESCE を staging モデル(一番上流)に閉じ込め、下流の mart モデルは NULL がないことを前提に設計する「早期 NULL 解消パターン」が一般的です。こうすることでビジネスロジックのクエリがシンプルになり、NULL 由来のバグが減ります。
QUESTION 4

AVG と NULL — 「平均が静かに嘘をつく」メカニズムを理解する

AVG と NULLCOUNT(*) vs COUNT(col)欠損値と集計回答率計算
前提知識

集約関数は NULL を自動的に除外して計算するため、結果の分母が変わります。これが「平均が静かに嘘をつく」バグの源です。

-- score = [80, NULL, 90, NULL] の場合
AVG(score)                -- = (80 + 90) / 2 = 85.0  ← 分母は 2(NULL 除外)
COUNT(score)              -- = 2  (NULL を除いた非 NULL 件数)
COUNT(*)                  -- = 4  (全件数)
COUNT(*) - COUNT(score)  -- = 2  (NULL 件数 = 未回答数)

-- NULL を 0 として全員を平均に含めたい場合
AVG(COALESCE(score, 0)) -- = (80 + 0 + 90 + 0) / 4 = 42.5
「回答者の平均」と「全員の平均」は別物:AVG(score) は回答者(非 NULL)の平均、AVG(COALESCE(score, 0)) は未回答を 0 点とした全員の平均です。どちらが正しいかはビジネス要件によって変わります。常に COUNT(*) と COUNT(col) をセットで確認し、欠損率を把握することが重要です。
問題

survey テーブルから、各カテゴリの平均スコア(avg_score)・回答者数(responded)・未回答者数(not_responded)・回答率(%)(response_rate)を計算してください。出力列は category, avg_score, responded, not_responded, response_rate、category 昇順で返してください。avg_score・response_rate は小数第1位まで丸めてください。

使用テーブル
► survey(8行)
respondent_idcategoryscoresubmitted_at
1A802024-01-01
2ANULLNULL
3A902024-01-02
4B702024-01-01
5BNULLNULL
6BNULLNULL
7B602024-01-03
8ANULLNULL
期待出力
categoryavg_scorerespondednot_respondedresponse_rate
A85.02250.0
B65.02250.0
模範解答コード
SELECT
  category,
  ROUND(AVG(score), 1)                         AS avg_score,     -- NULL 除外の平均
  COUNT(score)                                   AS responded,    -- 非 NULL 件数
  COUNT(*) - COUNT(score)                        AS not_responded,-- NULL 件数
  ROUND(COUNT(score) * 100.0 / COUNT(*), 1)     AS response_rate -- 回答率 (%)
FROM  survey
GROUP BY category
ORDER BY category;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM survey                         → 行を読込
  2. GROUP BY category                   → カテゴリでグループ化
  3. AVG(score)                          → NULL を除外して平均(回答者平均)
  4. COUNT(score)                        → 回答者数を集計
  5. COUNT(*)                            → 対象者数を集計
  6. COUNT(*) - COUNT(score)             → 未回答者数を算出
  7. COUNT(score) * 100.0 / COUNT(*)     → 回答率を算出
  8. ROUND / SELECT / ORDER BY category  → 丸めて出力
  */
解説(テーブル変化・ポイント)
SELECT category, ROUND(AVG(score), 1) AS avg_score, COUNT(score) AS responded, COUNT(*) - COUNT(score) AS not_responded, ROUND(COUNT(score) * 100.0 / COUNT(*), 1) AS response_rate FROM survey GROUP BY category ORDER BY category;
LEGEND
データ取得・読込対象
① FROM survey(8行)
FROM surveysurvey テーブルの8行を読み込みます。score と submitted_at が NULL の行(respondent_id=2,5,6,8)が未回答者です。この NULL を AVG がどう処理するかが今回のポイントです。
1 / 4
respondent_idcategoryscoresubmitted_at
1A802024-01-01
2ANULLNULL
3A902024-01-02
4B702024-01-01
5BNULLNULL
6BNULLNULL
7B602024-01-03
8ANULLNULL
8行読込
学習ポイント
AVG は NULL を無視し、分母も NULL 行を引く:AVG(score) は NULL を除いた行の合計 ÷ 非 NULL 行数で計算します。4行のうち 2 行が NULL なら分母は 2 になります。「全員の平均」ではなく「回答者の平均」になることを常に意識してください。NULL が多い列では AVG の分母が大きく変わります。
COUNT(*) − COUNT(col) で NULL 件数を効率よく求める:全行数から非 NULL 行数を引くと NULL の件数が得られます。これは SUM(CASE WHEN score IS NULL THEN 1 ELSE 0 END) と等価ですが、より簡潔です。欠損数を常に把握することが分析品質の基本です。
NULL を 0 として平均したい場合は COALESCE を使う:AVG(COALESCE(score, 0)) とすると未回答を 0 点として全員の平均を計算します。ビジネス要件によって「回答者の平均」と「全員の平均(未回答=0点)」を使い分けてください。どちらを使うかはデータ仕様書に明記することが重要です。
アンチパターン
AVG が NULL を無視することを知らずに「全員の平均」と誤解する:NULL が多い列で AVG を使うと、実際より小さいサンプルで計算された平均になります。常に COUNT(*) と COUNT(col) をセットで出力し、欠損率(= 1 - COUNT(col)/COUNT(*))を確認してください。欠損率が 30% を超えるなら、その平均値の代表性は低いと判断すべきです。
GROUP BY 後に AVG を使いグループ間の欠損率の差を見落とす:グループによって NULL の偏りが異なる場合、各グループの AVG は異なるサンプルサイズで計算されます。グループ A の回答率 90% vs グループ B の回答率 20% で avg_score を単純比較しても意味がありません。必ず response_rate とセットで解釈してください。
実務コラム:アンケート・ユーザースコア分析での欠損率管理
アンケートデータや評価スコアの NULL は「未回答」であり、0 とは根本的に異なります。平均スコアだけをレポートすると欠損の存在が隠れてしまいます。avg_score + response_rate をセットで報告することで、データの信頼性を正確に伝えられます。特に回答率が 50% を下回る場合、その平均スコアは代表性が低いため解釈に注意が必要です。dbt では responded / not_responded / response_rate を必ず avg_score と同じモデルに含める設計がデータ品質管理のベストプラクティスです。
QUESTION 5

LEFT JOIN と NULL — 「一致なし」が NULL を生むアンチジョインパターン

LEFT JOINIS NULLアンチジョイン未購入顧客抽出
前提知識

LEFT JOIN は左テーブルの全行を保持し、右テーブルに一致行がない場合は右テーブルの全列を NULL で補完します。この性質を利用して「一致しない行を抽出する」アンチジョインが実現できます。

SELECT c.name, o.order_id
FROM   customers c
LEFT JOIN orders o
  ON c.customer_id = o.customer_id

-- customer_id=2,4: orders に一致なし → o.order_id = NULL
WHERE o.order_id IS NULL  -- 一致なし(未購入)の行だけを通過
アンチジョイン(Anti-Join)パターン:LEFT JOIN + WHERE right_table.pk IS NULL は右テーブルに対応行が存在しない左テーブルの行を抽出します。NOT IN サブクエリは右テーブルに NULL が含まれると全行除外のリスクがあり、LEFT JOIN + IS NULL がより安全でパフォーマンスも良好なパターンです。
問題

customers テーブルと orders テーブルを LEFT JOIN して、一度も注文していない顧客(未購入顧客)を抽出してください。出力列は customer_id, name、customer_id 昇順で返してください。

使用テーブル
► customers(5行)
customer_idname
1田中
2鈴木
3佐藤
4伊藤
5山田
► orders(4行)
order_idcustomer_idamount
113000
235000
312000
451500
期待出力
customer_idname
2鈴木
4伊藤
模範解答コード
SELECT
  c.customer_id,
  c.name
FROM  customers c
LEFT JOIN orders o
  ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL   -- 右テーブルが NULL = 一致なし(未購入顧客)
ORDER BY c.customer_id;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM customers c                         → 行を読込
  2. LEFT JOIN orders o                       → 左テーブル全行を保持して結合
  3. WHERE o.order_id IS NULL                 → 右が NULL の行だけ通過(未購入)
  4. SELECT c.customer_id, c.name / ORDER BY  → customer_id 昇順で出力
  */
解説(テーブル変化・ポイント)
SELECT c.customer_id, c.name FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id WHERE o.order_id IS NULL ORDER BY c.customer_id;
LEGEND
データ取得・読込対象
① FROM customers(5行)
FROM customers ccustomers テーブルの5行を読み込みます。この全行に対して LEFT JOIN を行い、orders に注文記録がない顧客を NULL で検出します。
1 / 4
customer_idname
1田中
2鈴木
3佐藤
4伊藤
5山田
5行読込
学習ポイント
LEFT JOIN で一致なしの行は右テーブルが NULL になる:LEFT JOIN は左テーブルの全行を保持し、右テーブルに対応行がない場合はそのテーブルの全列が NULL になります。WHERE right_table.pk IS NULL でこの「一致なし」行だけを抽出するパターンが「アンチジョイン(Anti-Join)」です。未購入顧客・未フォロー顧客・未完了タスクなど「〜していないもの」の抽出に広く使われます。
NOT IN サブクエリに NULL があると全行除外:WHERE customer_id NOT IN (SELECT customer_id FROM orders) は、orders.customer_id に NULL が1件でも含まれると NOT IN (..., NULL, ...) が UNKNOWN を返し、全行が除外されます。NOT IN の代わりに NOT EXISTS か LEFT JOIN + IS NULL を使うことで、この NULL トラップを安全に回避できます。
WHERE 句の条件を JOIN 後に書くと LEFT JOIN が INNER JOIN になる:WHERE o.status = 'paid' を LEFT JOIN 後に書くと、o.status が NULL の行(一致なし行)は NULL = 'paid' が UNKNOWN になり除外されます。右テーブルへの絞り込みは ON 句に書く(結合条件の一部にする)か、サブクエリで事前に絞り込んでから LEFT JOIN するのが正しい設計です。
アンチパターン
WHERE 句に右テーブルの絞り込みを書いて LEFT JOIN を壊す:LEFT JOIN orders o ON ... WHERE o.status = 'paid' と書くと、NULL 行(一致なし行)が NULL = 'paid' → UNKNOWN で除外され、実質 INNER JOIN になります。RIGHT テーブルへのフィルタは ON c.id = o.customer_id AND o.status = 'paid' と ON 句に含めてください。ON 句に書けば一致なし行は NULL のまま保持されます。
WHERE o.order_id = NULL でアンチジョインを書く:WHERE o.order_id = NULL と書くと等値比較が UNKNOWN になり、結果は常に0件になります。RIGHT テーブルの NULL 検出は必ず IS NULL を使ってください。これは IS NULL の鉄則がアンチジョインパターンでも同様に適用される例です。
実務コラム:アンチジョインの実務活用と3パターン比較
未購入顧客・未フォロー顧客・未完了タスクなど「〜していないもの」の抽出は、データ分析で頻繁に現れるパターンです。3つの書き方を比較すると:① LEFT JOIN + IS NULL(推奨):シンプルで安全、NULL トラップなし、実行計画も効率的。② NOT EXISTS:相関サブクエリで安全だがやや冗長。③ NOT IN:右テーブルに NULL がないことが保証される場合のみ使用可能。実務では LEFT JOIN + IS NULL が最もシンプルで安全なアンチジョインパターンとして標準的に使われています。