SQLで「NULL = NULL」がTRUEにならないのはなぜ?PostgreSQLで三値論理を検証

当ページのリンクには広告が含まれています。
SQLでNULL = NULLがTRUEではなくUNKNOWNになり、IS NULLではTRUEになる三値論理の違いを示した図解

SQLで NULL = NULL を評価した結果は、TRUEではなくNULL(UNKNOWN)です。NULLかどうかを判定するときは= NULLではなくIS NULLを使います。

この記事ではNULL = NULLがTRUEにならない理由を三値論理から整理し、PostgreSQLで実際に検証します。IS NULLIS DISTINCT FROMの使い分け、NOT INにNULLが混ざった場合の注意点も確認します。

目次

早見表:NULLがからむ比較の結果

先に結論だけまとめておきます。細かい理屈は後段で説明するので、まずはここだけ覚えておけば実務では困りません。

比較結果
NULL = NULLNULL(unknown)
NULL <> NULLNULL(unknown)
NULL IS NULLTRUE
NULL IS NOT NULLFALSE
NULL IS DISTINCT FROM NULLFALSE
NULL IS NOT DISTINCT FROM NULLTRUE

そもそもなぜ「NULL = NULL」はTRUEにならないのか

SQLのNULLは、通常の値が入っていない状態を示す特別なマーカーです。未入力、未確認、該当なしなど複数の意味を持つ可能性があり、NULLだけを見ても本来の値や理由は判断できません。

例えば、会員テーブルの電話番号カラムがNULLでも「電話番号を持っていない」のか「まだ入力されていない」のかは分かりません。そのため、分からない状態同士を比較したNULL = NULLも「同じ」とは断定できず、結果はUNKNOWNになります。

SQLの条件式はTRUE・FALSE・UNKNOWNの3つの状態を取ります。これが三値論理です。WHERE句が返すのは条件がTRUEになった行だけなので、FALSEだけでなくUNKNOWNの行も結果から除外されます。

PostgreSQLの公式ドキュメントでも= NULLを使わずIS NULLを使うよう説明されています。

実際にPostgreSQLで検証してみる

検証環境(確認日:2026年8月)
OS

Ubuntu 24.04

PostgreSQL

16.14

この節で扱う通常の比較演算子と三値論理はSQLの基本的な仕様です。PostgreSQL固有の機能やバージョン条件がある箇所は、その都度説明します。

psqlはデフォルト設定だとNULLを空欄で表示するので、結果が本当にNULLなのか単なる空文字なのか判りづらいです。metaコマンドの\psetで表示を変更できます。
\pset null '[NULL]'

SQL
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 NULLIS NOT NULLですよね。これらは比較演算子ではなく専用の述語なので、対象がNULLかどうかを必ずTRUE・FALSEで返してくれます

サンプルとしてこんなテーブルとデータを用意しました。

SQL
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件になります。

SQL
SELECT * FROM memo WHERE tag = NULL;
実行結果
 id | tag
----+-----
(0 rows)

IS NULL / IS NOT NULLに書き換えると、期待どおりの結果が返ってきます。

SQL
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 FROMIS NOT DISTINCT FROMです。これらはNULLを「分からない値」ではなく、あたかも普通の値であるかのように扱って比較してくれます。

SQL
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件になります。

除外リスト用のテーブルを使って再現してみます。

SQL
CREATE TABLE excluded_tags (tag text);
INSERT INTO excluded_tags VALUES ('private'), (NULL);

‘private’だけを除外したいつもりで、こう書いたとします。

SQL
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を除いてしまうことです。

SQL
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を動かしてみます。

SQL
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を入れても制約違反になりません。

SQL
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は、次の記事で具体例を紹介しています。

よくある疑問

IS NULLとIS NOT DISTINCT FROM NULLは同じ意味?

単純に「NULLかどうか」を判定するだけならこの2つは同じ結果になります。IS NULLのほうが意図が伝わりやすく一般的です。

MySQLやSQL Serverでも同じ挙動になりますか?

三値論理そのものはSQL標準の考え方なので、基本的な方向性はどのRDBMSでも共通です。ただしUNIQUE制約でのNULLの扱いなど、細かい部分は実装によって差が出ることもあるので、他のDBを使う際は該当バージョンの公式ドキュメントであわせて確認しておくと安心です。

まとめ

  • 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環境構築の記事は、データベースまとめから目的別に探せます。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次