SQLでNULLが絡む条件式を書くとき、「なぜかデータが取れない」「NOT INで期待した結果にならない」「JOINしたら件数が合わない」といった問題に悩まされることがよくあります。
原因の多くは三値論理(TRUE / FALSE / UNKNOWN)の動作を知らずにコードを書いていることです。本記事では、NULLを含む条件分岐・フィルターで必ず押さえておくべき知識と、現場で使える実践パターンをすべて解説します。
- 三値論理(UNKNOWN)が引き起こすバグのパターン
- AND / OR / NOT での UNKNOWN 伝播の完全な動作表
- COALESCE / NULLIF / NVL / IFNULL の使い分けと実践パターン
- NOT IN + NULL が機能しない理由と安全な代替手法
- OUTER JOIN 後のNULLフィルターで INNER JOIN 相当を実現する方法
- ウィンドウ関数の IGNORE NULLS(Oracle / SQL Server)
- GROUP BY・HAVING・CASE WHEN での NULL の動作
なぜ WHERE col = NULL は動かないのか
SQLを始めたばかりのころ、多くの人がこのミスをします。
-- NULLを比較演算子で比較してもヒットしない SELECT * FROM users WHERE email = NULL; -- 0件 SELECT * FROM users WHERE email != NULL; -- 0件
なぜ0件になるのでしょうか。SQLには三値論理という仕組みがあり、比較結果は TRUE / FALSE だけでなく UNKNOWN になることがあります。
NULL = NULLの結果は TRUE でも FALSE でもなく UNKNOWN です。WHERE句はUNKNOWNを「条件を満たしていない」として扱うため、NULL同士の比較ではレコードが返らないのです。
-- NULLの判定には IS NULL / IS NOT NULL を使う SELECT * FROM users WHERE email IS NULL; SELECT * FROM users WHERE email IS NOT NULL;
三値論理(UNKNOWN)の完全な動作表
NULLが絡む条件式を正しく書くために、UNKNOWN の伝播ルールを理解することが重要です。
AND 演算表
| A | B | A AND B |
|---|---|---|
| TRUE | TRUE | TRUE |
| TRUE | FALSE | FALSE |
| TRUE | UNKNOWN | UNKNOWN |
| FALSE | TRUE | FALSE |
| FALSE | FALSE | FALSE |
| FALSE | UNKNOWN | FALSE |
| UNKNOWN | TRUE | UNKNOWN |
| UNKNOWN | FALSE | FALSE |
| UNKNOWN | UNKNOWN | UNKNOWN |
OR 演算表
| A | B | A OR B |
|---|---|---|
| TRUE | TRUE | TRUE |
| TRUE | FALSE | TRUE |
| TRUE | UNKNOWN | TRUE |
| FALSE | TRUE | TRUE |
| FALSE | FALSE | FALSE |
| FALSE | UNKNOWN | UNKNOWN |
| UNKNOWN | TRUE | TRUE |
| UNKNOWN | FALSE | UNKNOWN |
| UNKNOWN | UNKNOWN | UNKNOWN |
NOT 演算表
| A | NOT A |
|---|---|
| TRUE | FALSE |
| FALSE | TRUE |
| UNKNOWN | UNKNOWN |
① AND:FALSE が1つでも全体が FALSE(UNKNOWNは伝播する)
② OR:TRUE が1つでも全体が TRUE(UNKNOWNは伝播する)
③ NOT NULL:UNKNOWN のまま(TRUE にはならない)
WHERE 句は TRUE の行だけを返す → UNKNOWN は「除外」として扱われる
AND / OR での UNKNOWN 伝播パターン
三値論理の動作を理解すると、複合条件でのNULL絡みのバグが見えてきます。
-- scoreがNULLの行は UNKNOWN AND TRUE = UNKNOWN → 除外される SELECT * FROM students WHERE score >= 80 AND grade = 'A'; -- scoreがNULLでもORで救える(UNKNOWN OR TRUE = TRUE) SELECT * FROM students WHERE score >= 80 OR score IS NULL; -- NOT (score < 60) はNULLをヒットさせない -- NOT UNKNOWN = UNKNOWN のため SELECT * FROM students WHERE NOT (score < 60); -- NULLのscoreは除外される -- NULLを含む行を拾いたい場合は明示的に OR IS NULL SELECT * FROM students WHERE NOT (score < 60) OR score IS NULL;
NOT (score < 60)は「60未満ではない」という意味ですが、scoreがNULLの行はNOT UNKNOWN = UNKNOWNとなり、WHERE句に除外されます。「60未満でもなくNULLでもない」ではなく「NULLも含めて取得したい」場合は
OR score IS NULLを追加してください。NOT IN + NULL の危険な落とし穴
実務で最も踏みやすいNULL罠がこれです。NOT INのリストにNULLが1件でも含まれると全件0件になります。
-- productsテーブル -- id: 1, 2, 3, 4, 5 -- deleted_idsテーブルにNULLが含まれる場合 -- id: 2, 4, NULL -- 「2と4以外のproductを取得したい」つもりが... SELECT * FROM products WHERE id NOT IN (SELECT id FROM deleted_ids); -- → 結果: 0件 !! NULLが含まれるためすべてUNKNOWNになる -- 内部的には以下の条件に展開される: -- id NOT IN (2, 4, NULL) -- = id <> 2 AND id <> 4 AND id <> NULL -- = id <> 2 AND id <> 4 AND UNKNOWN -- = UNKNOWN → 全行除外
-- NOT EXISTS はNULLの影響を受けない
SELECT * FROM products p
WHERE NOT EXISTS (
SELECT 1 FROM deleted_ids d
WHERE d.id = p.id
);
-- または WHERE NULLを除外した上でNOT IN
SELECT * FROM products
WHERE id NOT IN (
SELECT id FROM deleted_ids
WHERE id IS NOT NULL -- NULLを除外してからNOT IN
);
サブクエリ結果にNULLが入る可能性がある場合は
NOT EXISTSが安全です。リテラル値のリストにNULLが入ることはないのでWHERE id NOT IN (1, 2, 3)は問題ありません。詳しくはNOT IN完全ガイドをご参照ください。NULL補完関数の使い分け
NULLを別の値に置き換える関数は複数あります。DBMSによって使える関数が異なるため、違いを整理します。
| 関数 | MySQL | PostgreSQL | Oracle | SQL Server | 複数の候補値 |
|---|---|---|---|---|---|
COALESCE(a, b, ...) |
○ | ○ | ○ | ○ | ○(全DBMS標準) |
IFNULL(a, b) |
○ | × | × | × | ×(2引数のみ) |
NVL(a, b) |
× | × | ○ | × | ×(Oracle専用) |
NVL2(a, b, c) |
× | × | ○ | × | ×(Oracle専用) |
ISNULL(a, b) |
× | × | × | ○ | ×(SS専用) |
-- 最初の非NULLを返す(全DBMS対応)
SELECT
name,
COALESCE(mobile_phone, home_phone, '連絡先未登録') AS contact
FROM customers;
-- WHERE句でCOALESCEを使う
-- discount_priceがNULLのときはlist_priceで比較
SELECT * FROM products
WHERE COALESCE(discount_price, list_price) < 5000;
-- MySQL: IFNULL SELECT name, IFNULL(score, 0) AS score FROM students; -- Oracle: NVL SELECT name, NVL(score, 0) AS score FROM students; -- Oracle: NVL2(col, NULLでない場合の値, NULLの場合の値) SELECT name, NVL2(score, '採点済み', '未採点') AS status FROM students; -- SQL Server: ISNULL SELECT name, ISNULL(score, 0) AS score FROM students;
NULLIF:特定の値をNULLに変換する
NULLIF(a, b)は a = b のとき NULL を返し、それ以外は a をそのまま返す 関数です。「ゼロ除算の防止」や「空文字をNULLとして扱う」場面でよく使います。
-- ゼロ除算の防止(NULLIF で分母がゼロの場合はNULLに)
SELECT
department,
total_sales,
ROUND(total_sales / NULLIF(target_sales, 0) * 100, 1) AS achievement_rate
FROM sales_summary;
-- 空文字をNULLとして扱う
SELECT
name,
NULLIF(remarks, '') AS remarks -- 空文字→NULL
FROM customers;
-- COALESCE + NULLIF の組み合わせ
-- 空文字もNULLも「未入力」として表示
SELECT
name,
COALESCE(NULLIF(remarks, ''), '未入力') AS remarks
FROM customers;
CASE WHEN でのNULL条件分岐
CASE式でNULLを扱う際の注意点と実践パターンです。
-- 基本:NULLの有無で表示を切り替える
SELECT
user_id,
name,
CASE
WHEN email IS NULL THEN 'メール未登録'
WHEN email LIKE '%@gmail.com' THEN 'Gmail'
ELSE 'その他メール'
END AS email_status
FROM users;
-- NG: CASE WHEN email = NULL ← 絶対にヒットしない
SELECT
CASE WHEN email = NULL THEN '未登録' -- 常にFALSE扱い
ELSE email
END
FROM users; -- BAD: IS NULLを使うこと
-- 必須項目の入力状況を分類
SELECT
user_id,
CASE
WHEN email IS NOT NULL AND phone IS NOT NULL THEN '完全登録'
WHEN email IS NOT NULL AND phone IS NULL THEN 'メールのみ'
WHEN email IS NULL AND phone IS NOT NULL THEN '電話のみ'
ELSE '未登録'
END AS registration_status
FROM users;
-- ELSE を省略するとNULLが返る
SELECT
CASE WHEN score >= 80 THEN '合格'
WHEN score >= 60 THEN '補講'
-- ELSE を書かなかった場合: NULL が返る
END AS result
FROM students;
-- NULLにしたくない場合はELSEを明示
SELECT
CASE WHEN score >= 80 THEN '合格'
WHEN score >= 60 THEN '補講'
ELSE '不合格'
END AS result
FROM students;
OUTER JOIN とNULLフィルター
LEFT JOINを使うと、右テーブルに対応行がない場合はNULLが埋まります。このNULLを使ったフィルタリングは実務で頻繁に登場します。
-- 注文のないcustomerを取得(ANTI JOINパターン) SELECT c.customer_id, c.name FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id WHERE o.customer_id IS NULL; -- JOINに失敗→NULLの行のみ -- 右テーブルの非NULL行のみ(INNER JOINと同等) SELECT c.customer_id, c.name, o.order_date FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id WHERE o.customer_id IS NOT NULL;
LEFT JOINでフィルターをON句に書くかWHERE句に書くかで結果が変わります。
ON o.status = 'confirmed':statusが非confimredの場合でもcustomerは残り、o.*がNULLになるWHERE o.status = 'confirmed':INNER JOINと同等になり、statusが非confirmedのcustomerが除外される-- ON句のフィルター: LEFT JOINの結合条件として使う
-- → customerは全件残り、直近30日の注文がない場合はo.*がNULL
SELECT c.customer_id, c.name, o.order_date
FROM customers c
LEFT JOIN orders o
ON c.customer_id = o.customer_id
AND o.order_date >= CURRENT_DATE - INTERVAL 30 DAY;
-- WHERE句のフィルター: JOINの後でフィルター
-- → 直近30日に注文していないcustomerは除外される(INNER JOIN相当)
SELECT c.customer_id, c.name, o.order_date
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_date >= CURRENT_DATE - INTERVAL 30 DAY;
GROUP BY / HAVING でのNULLの扱い
GROUP BYはNULLを「1つのグループ」として扱います。集計関数は通常NULLを除外するため、意識しないと件数が合わなくなります。
-- NULLのdepartmentは「NULLグループ」として集計される
SELECT
department, -- NULLのdepartmentも1行として出る
COUNT(*) AS total, -- NULLのdepartmentの人数
COUNT(score) AS scored_count, -- NULLのscoreは除外
AVG(score) AS avg_score -- NULLのscoreは除外して平均
FROM employees
GROUP BY department;
-- NULLグループを除外したい場合はWHEREで先にフィルタ
SELECT department, COUNT(*)
FROM employees
WHERE department IS NOT NULL
GROUP BY department;
-- 平均スコアがNULL(全員スコア未入力)のグループを除外 SELECT department, AVG(score) AS avg_score FROM employees GROUP BY department HAVING AVG(score) IS NOT NULL; -- 「未入力0件のグループ」のみを抽出するパターン SELECT department, COUNT(*) AS total, COUNT(score) AS scored FROM employees GROUP BY department HAVING COUNT(*) = COUNT(score); -- 全員スコアあり
ウィンドウ関数での IGNORE NULLS
Oracle と SQL Server では、LAST_VALUE・FIRST_VALUE・LAG・LEAD などのウィンドウ関数で IGNORE NULLS オプションを使えます。センサーデータや株価のような「欠損値を直前の値で補完する」用途で重宝します。
-- センサーデータ(NULLは計測失敗)
-- Oracle: LAG ... IGNORE NULLS
SELECT
measured_at,
temperature,
LAST_VALUE(temperature IGNORE NULLS)
OVER (ORDER BY measured_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
AS filled_temperature
FROM sensor_data
ORDER BY measured_at;
-- PostgreSQL には IGNORE NULLS がないため CTE で代替
WITH filled AS (
SELECT
measured_at,
temperature,
COUNT(temperature) OVER (ORDER BY measured_at) AS grp
FROM sensor_data
)
SELECT
measured_at,
temperature,
FIRST_VALUE(temperature) OVER (PARTITION BY grp ORDER BY measured_at)
AS filled_temperature
FROM filled
ORDER BY measured_at;
実践パターン集
パターン1:フォーム入力の入力状況チェック
SELECT
COUNT(*) AS total_users,
COUNT(email) AS email_filled,
COUNT(phone) AS phone_filled,
COUNT(CASE WHEN email IS NULL AND phone IS NULL THEN 1 END)
AS both_missing,
ROUND(100.0 * COUNT(email) / COUNT(*), 1)
AS email_fill_rate
FROM users;
パターン2:データクレンジング(空文字・NULLの統一)
-- 空文字とNULLが混在するカラムを一括クレンジング
UPDATE users
SET
phone = NULLIF(TRIM(phone), ''),
address = NULLIF(TRIM(address), '')
WHERE phone = '' OR address = '';
-- 表示時に空文字・NULLを両方「未登録」として扱う
SELECT
name,
COALESCE(NULLIF(TRIM(phone), ''), '未登録') AS phone
FROM users;
パターン3:NULLを含む比較で「不明も含める」か「除外する」かを制御
-- 「年齢20歳以上」のユーザーを取得(NULLは除外) SELECT * FROM users WHERE age >= 20; -- 「年齢20歳以上 または 年齢不明」を取得 SELECT * FROM users WHERE age >= 20 OR age IS NULL; -- 「年齢18〜65歳の範囲外 または 不明」を取得 SELECT * FROM users WHERE NOT (age BETWEEN 18 AND 65) OR age IS NULL;
パターン4:集計レポートでNULLをゼロに揃える
-- 売上サマリーで売上がなかった月をゼロ表示
SELECT
m.month_str,
COALESCE(s.total_sales, 0) AS total_sales,
COALESCE(s.order_count, 0) AS order_count
FROM (
-- 全月マスタ
SELECT DATE_FORMAT(DATE_ADD('2024-01-01', INTERVAL n MONTH), '%Y-%m')
AS month_str
FROM (SELECT 0 n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3
UNION SELECT 4 UNION SELECT 5 UNION SELECT 6
UNION SELECT 7 UNION SELECT 8 UNION SELECT 9
UNION SELECT 10 UNION SELECT 11) nums
) m
LEFT JOIN (
SELECT DATE_FORMAT(order_date, '%Y-%m') AS month_str,
SUM(amount) AS total_sales,
COUNT(*) AS order_count
FROM orders
WHERE order_date BETWEEN '2024-01-01' AND '2024-12-31'
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
) s ON m.month_str = s.month_str
ORDER BY m.month_str;
よくある質問
COALESCE(col, '代替値')はNULLを別の値に置き換える関数で簡潔に書けます。CASE WHEN col IS NULL THEN '代替値' ELSE col ENDはより柔軟で、NULLかどうかに加えて他の条件も組み合わせられます。単純な補完はCOALESCE、複雑な分岐はCASE WHENを使うのが一般的です。col != '値'はNULLに対してUNKNOWNを返し、WHERE句ではUNKNOWNは除外されます。NULLも含めて取得したい場合はWHERE (col != '値' OR col IS NULL)と明示的に書いてください。=ではNULL = NULLはUNKNOWNです。「NULLを同じと見なす」にはIS NOT DISTINCT FROM(PostgreSQL)またはCASE WHEN a IS NULL AND b IS NULL THEN TRUE WHEN a = b THEN TRUE ELSE FALSE ENDのような書き方を使います。MySQLでは<=>(NULL安全等価演算子)が使えます。a <=> bはNULLを通常の値として等価比較します。NULL <=> NULLはTRUE、NULL <=> 1はFALSEになります。MySQLとMariaDB専用の演算子で、PostgreSQLではIS NOT DISTINCT FROM、OracleではDECODE(a, b, TRUE, FALSE)が近い動作をします。まとめ
NULLを含む条件分岐・フィルターの要点を整理します。
| 場面 | 正しい書き方 | NGパターン |
|---|---|---|
| NULLの検索 | IS NULL |
= NULL |
| NULL除外 | IS NOT NULL |
!= NULL |
| NOT INのNULL対策 | NOT EXISTSまたはWHERE id IS NOT NULLでサブクエリ除外 |
NOT IN(NULLが1件でも全件0件に) |
| NULLを別値に置換 | COALESCE(col, 値) |
CASE WHEN col = NULL |
| 特定値をNULLに変換 | NULLIF(col, 値) |
— |
| 否定条件でNULLを含む | NOT (条件) OR col IS NULL |
NOT (条件)(NULLが除外される) |
| LEFT JOIN後の絞り込み | ON句 or WHERE句を意図に合わせて選択 | 意識せず混在させる |
関連記事:IS NULL完全ガイド(NULL検索・集計・ORDER BY NULLS)、IS NOT NULL完全ガイド、NOT IN完全ガイド、CASE WHEN完全ガイド

