SQLで NULL = NULL を評価した結果は、TRUEではなくNULL(UNKNOWN)です。NULLかどうかを判定するときは= NULLではなくIS NULLを使います。
この記事ではNULL = NULLがTRUEにならない理由を三値論理から整理し、PostgreSQLで実際に検証します。IS NULLとIS DISTINCT FROMの使い分け、NOT INにNULLが混ざった場合の注意点も確認します。
早見表:NULLがからむ比較の結果
先に結論だけまとめておきます。細かい理屈は後段で説明するので、まずはここだけ覚えておけば実務では困りません。
| 比較 | 結果 |
|---|---|
NULL = NULL | NULL(unknown) |
NULL <> NULL | NULL(unknown) |
NULL IS NULL | TRUE |
NULL IS NOT NULL | FALSE |
NULL IS DISTINCT FROM NULL | FALSE |
NULL IS NOT DISTINCT FROM NULL | TRUE |
そもそもなぜ「NULL = NULL」はTRUEにならないのか
SQLのNULLは、通常の値が入っていない状態を示す特別なマーカーです。未入力、未確認、該当なしなど複数の意味を持つ可能性があり、NULLだけを見ても本来の値や理由は判断できません。
例えば、会員テーブルの電話番号カラムがNULLでも「電話番号を持っていない」のか「まだ入力されていない」のかは分かりません。そのため、分からない状態同士を比較したNULL = NULLも「同じ」とは断定できず、結果はUNKNOWNになります。
SQLの条件式はTRUE・FALSE・UNKNOWNの3つの状態を取ります。これが三値論理です。WHERE句が返すのは条件がTRUEになった行だけなので、FALSEだけでなくUNKNOWNの行も結果から除外されます。
PostgreSQLの公式ドキュメントでも= NULLを使わずIS NULLを使うよう説明されています。
実際にPostgreSQLで検証してみる
- OS
Ubuntu 24.04
- PostgreSQL
16.14
この節で扱う通常の比較演算子と三値論理はSQLの基本的な仕様です。PostgreSQL固有の機能やバージョン条件がある箇所は、その都度説明します。
psqlはデフォルト設定だとNULLを空欄で表示するので、結果が本当にNULLなのか単なる空文字なのか判りづらいです。metaコマンドの\psetで表示を変更できます。\pset null '[NULL]'
SELECT
NULL = NULL AS "eq"
, NULL <> NULL AS "ne"
, 1 = NULL AS "num_eq"; eq | ne | num_eq
--------+--------+--------
[NULL] | [NULL] | [NULL]
(1 row)3つとも見事にNULLになりました。TRUEでもFALSEでもなく「分からない」という第3の答えが返ってきているわけです。
PostgreSQLを手元に用意してSQLを試したい場合はDocker Composeを使った開発環境の作り方も参考になります。


IS NULLを使えばちゃんと判定できる
= NULLで判定できないならどう書けばいいのか、というのがIS NULLとIS NOT NULLですよね。これらは比較演算子ではなく専用の述語なので、対象がNULLかどうかを必ずTRUE・FALSEで返してくれます。
サンプルとしてこんなテーブルとデータを用意しました。
CREATE TABLE memo (id int, tag text);
INSERT INTO memo VALUES (1, 'work'), (2, 'private'), (3, NULL), (4, 'work'); id | tag
----+---------
1 | work
2 | private
3 | [NULL]
4 | work
(4 rows)ここでtag = NULLと書くと、先ほどの理屈どおり0件になります。
SELECT * FROM memo WHERE tag = NULL; id | tag
----+-----
(0 rows)IS NULL / IS NOT NULLに書き換えると、期待どおりの結果が返ってきます。
SELECT * FROM memo WHERE tag IS NULL;
SELECT * FROM memo WHERE tag IS NOT NULL; id | tag
----+--------
3 | [NULL]
(1 row)
id | tag
----+---------
1 | work
2 | private
4 | work
(3 rows)タグが未設定のメモ(id=3)だけがちゃんと拾えています。実務ではまずこの2つを覚えておけば9割方のケースは対応できます。
NULLを「普通の値」として扱いたいときはIS DISTINCT FROM
IS NULLだけだと足りない場面もあります。たとえば2つのカラムの値が違うかどうかを判定したいとき、片方だけNULLだった場合や両方NULLだった場合まで含めてTRUE・FALSEで白黒つけたいことがあります。
そんなときに使うのがIS DISTINCT FROMとIS NOT DISTINCT FROMです。これらはNULLを「分からない値」ではなく、あたかも普通の値であるかのように扱って比較してくれます。
SELECT
NULL IS DISTINCT FROM NULL AS "a",
NULL IS NOT DISTINCT FROM NULL AS "b",
1 IS DISTINCT FROM NULL AS "c",
1 IS DISTINCT FROM 1 AS "d"; a | b | c | d
---+---+---+---
f | t | t | f
(1 row)NULL IS DISTINCT FROM NULLがFALSE(両方NULLなら「違わない」)になっているのがポイントです。通常の<>だとここもNULLになってしまうので、この一点だけでも使い分ける価値があります。
要注意:NOT INにNULLが混ざると条件ごと消える
NOT INの右辺にNULLが含まれている場合、左辺と一致する非NULL値が見つかれば結果はFALSEになります。一致する値がなく、右辺にNULLが1つでも含まれている場合はUNKNOWNです。
WHERE句がOKと判断してくれるのはTRUEの行だけなので、今回のデータではどの行も条件を通過せず結果が0件になります。
除外リスト用のテーブルを使って再現してみます。
CREATE TABLE excluded_tags (tag text);
INSERT INTO excluded_tags VALUES ('private'), (NULL);‘private’だけを除外したいつもりで、こう書いたとします。
SELECT * FROM memo
WHERE tag NOT IN (SELECT tag FROM excluded_tags); id | tag
----+-----
(0 rows)memoテーブルには’work’が2件あるはずなのに結果が0件ですね。privateの行は一致するためFALSE、workの行とmemo.tagがNULLの行はUNKNOWNになります。その結果、WHERE句を通る行がなく0件になります。PostgreSQLの公式ドキュメントでも比較先にNULLの行が含まれる場合NOT IN全体の結果がNULLになりうることが明記されています。
そうとわかってしまえば、対処法はシンプルでサブクエリ側でNULLを除いてしまうことです。
SELECT * FROM memo
WHERE tag NOT IN (SELECT tag FROM excluded_tags WHERE tag IS NOT NULL); id | tag
----+------
1 | work
4 | work
(2 rows)期待どおり2件返ってきました。NOT INのサブクエリを書くときは、NULLが紛れ込む可能性がないか一度確認する癖をつけておくと安全です。
なお、サブクエリ側からNULLを除いても左辺のmemo.tag自体がNULLの行はUNKNOWNになるため取得されません。
おまけ:GROUP BY・DISTINCT・UNIQUE制約でNULLの扱いがバラバラという話
ここまで「NULL同士は比較すると分からない扱いになる」と説明してきましたが、実はSQLの機能によってNULLの扱いが微妙に違います。少しややこしいですが、知っておくと後々のトラブルを防げます。
| 場面 | NULL同士の扱い |
|---|---|
=や<>などの比較演算子 | 分からない(unknown)扱い |
GROUP BY / DISTINCT | 同じグループとして1つにまとめる |
UNIQUE制約(デフォルト) | 別物として扱い、複数のNULLを許可する |
UNIQUE NULLS NOT DISTINCT(PostgreSQL 15以降) | 同じ値として扱い、NULLの重複を弾く |
試しにGROUP BYを動かしてみます。
CREATE TABLE memo2 (id int, tag text);
INSERT INTO memo2 VALUES (1, 'work'), (2, NULL), (3, NULL), (4, 'work');
SELECT tag, COUNT(*) FROM memo2 GROUP BY tag ORDER BY tag; tag | count
--------+-------
work | 2
[NULL] | 2
(2 rows)=で比較すればNULL同士はunknownになるはずなのにGROUP BYでは2件のNULLがまとめて1グループとして集計されています。SQL標準がGROUP BYやDISTINCTでは「両方NULLなら同じグループ」という別ルールを定義しているためで、比較演算子の話とは切り離して考える必要があります。
一方でUNIQUE制約はデフォルトだとNULL同士を別物として扱うので、同じカラムに複数のNULLを入れても制約違反になりません。
CREATE TABLE uniq_test (email text UNIQUE);
INSERT INTO uniq_test VALUES (NULL);
INSERT INTO uniq_test VALUES (NULL);INSERT 0 1
INSERT 0 1どちらも成功してしまいました。「UNIQUE制約をつけているのに、重複っぽいNULLが複数行入っている」というのは、この挙動を知らないとハマりやすいポイントです。
PostgreSQL 15以降ではUNIQUE NULLS NOT DISTINCTを指定するとNULL同士も重複として扱えます。例えばemail text UNIQUE NULLS NOT DISTINCTと定義した場合、emailがNULLの行を複数登録すると一意制約違反になります。
GROUP BYでNULLを含む重複レコードを調べるSQLは、次の記事で具体例を紹介しています。


よくある疑問
まとめ
- SQLのNULLは「分からない」を表していて、比較すると結果もNULL(unknown)になるよ。
= NULLではなくIS NULL/IS NOT NULLを使えば、ちゃんとTRUE・FALSEで判定できるよ。- NULLを普通の値として比較したいときは
IS DISTINCT FROMが便利だよ。 NOT INにNULLが混ざると結果が全部消えることがあるから、サブクエリ側は要注意だよ。- GROUP BYやUNIQUE制約はNULLの扱いがまた別ルールだから、混同しないようにしてね。
PostgreSQLのSQLや接続設定、Docker環境構築の記事は、データベースまとめから目的別に探せます。







