SQLのJOINでNULLを扱うときは、「NULL同士を同じものとして結合する」のか、「結合相手がなくても元の行を残す」のかを分けて考えます。通常の等価比較ではNULL同士は一致しませんが、外部結合なら一致しなかった側の行を残せます。本記事では、INNER・LEFT・RIGHT・FULL OUTER JOINの結果を同じデータで比較し、NULL安全な比較、COALESCEの落とし穴、ONとWHEREの違いを確認します。
実行例はSQLite 3.39.0以降向けです。掲載SQLはSQLite 3.53.1で検証しています。他のDBMSについては公式資料で確認した構文の違いを示しますが、各製品での実行検証は行っていません。既存の業務データベースではなく、新しい検証用接続で試してください。
JOIN操作におけるNULL値の基本挙動
NULLは値が不明・未設定であることを表します。通常の=や<>で片方または両方がNULLなら、比較結果はFALSEではなくUNKNOWN(不明)です。ONやWHEREではTRUEになった行だけが採用されるため、UNKNOWNの比較は一致条件を満たしません。NULLを含む行がすべて除外されるのではなく、どの列をどの条件で比較したかが重要です。
NULL値の比較の基本
SELECT NULL = NULL AS equals_null,
NULL <> NULL AS not_equals_null,
NOT (NULL = NULL) AS negated_comparison,
NULL IS NULL AS is_null;| equals_null | not_equals_null | negated_comparison | is_null |
|---|---|---|---|
| NULL | NULL | NULL | 1 |
SQLiteではUNKNOWNがNULL、TRUEが1、FALSEが0として表示されます。NOT UNKNOWNもUNKNOWNなので、「NULLとの比較はFALSE」と考えると否定条件で判断を誤ります。NULLそのものの検査にはIS NULLまたはIS NOT NULLを使い、= NULLや<> NULLとは書きません。参考:PostgreSQLの比較とNULL、SQLiteの式と比較演算子。
各JOINで使用する検証データ
元の例と同じAlice・Bob・Charlieと、HR・IT・Unknownを使います。部門側には、行の存在を確実に判定できるNOT NULLの主キーdepartment_row_idを追加します。比較用のdepartment_idはNULLを許可する別の列です。activeは後半のONとWHEREの比較に使います。
CREATE TEMP TABLE employees (
employee_id INTEGER NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
department_id INTEGER
);
CREATE TEMP TABLE departments (
department_row_id INTEGER NOT NULL PRIMARY KEY,
department_id INTEGER,
department_name TEXT,
active INTEGER NOT NULL
);
INSERT INTO employees (employee_id, name, department_id) VALUES
(1, 'Alice', 10),
(2, 'Bob', NULL),
(3, 'Charlie', 20);
INSERT INTO departments
(department_row_id, department_id, department_name, active) VALUES
(101, 10, 'HR', 1),
(102, 20, 'IT', 0),
(103, NULL, 'Unknown', 1);employeesテーブル
| employee_id | name | department_id |
|---|---|---|
| 1 | Alice | 10 |
| 2 | Bob | NULL |
| 3 | Charlie | 20 |
departmentsテーブル
| department_row_id | department_id | department_name | active |
|---|---|---|---|
| 101 | 10 | HR | 1 |
| 102 | 20 | IT | 0 |
| 103 | NULL | Unknown | 1 |
表中のNULLは文字列'NULL'ではなく、SQLのNULLです。実際の設計で部門IDを主キーにする場合、その主キーにNULLを格納する例ではありません。また、以下では左右のキーを取り違えないように列を明記します。SELECT *では左右両方の同名列も出力されるため、列数や意味がこの表と異なります。
INNER JOINにおけるNULL値の扱い
INNER JOINはON条件がTRUEになる行の組だけを返します。次のように=で結合する場合、BobのNULLとUnknown部門のNULLは一致せず、結果は2行です。表示用の列など、比較に使わない列にNULLがあるだけなら、それを理由に行が消えることはありません。
SELECT e.employee_id, e.name,
e.department_id AS employee_department_id,
d.department_row_id,
d.department_id AS matched_department_id,
d.department_name
FROM employees AS e
INNER JOIN departments AS d
ON e.department_id = d.department_id
ORDER BY e.employee_id, d.department_row_id;| employee_id | name | employee_department_id | department_row_id | matched_department_id | department_name |
|---|---|---|---|---|---|
| 1 | Alice | 10 | 101 | 10 | HR |
| 3 | Charlie | 20 | 102 | 20 | IT |
LEFT JOINにおけるNULL値の扱い
LEFT JOINは一致した組を返し、右側に一致する行がなかった左の行も残します。その行の右側の列にはNULLが補われます。LEFT JOINにしただけでNULL同士が一致するわけではありません。
SELECT e.employee_id, e.name,
e.department_id AS employee_department_id,
d.department_row_id,
d.department_id AS matched_department_id,
d.department_name
FROM employees AS e
LEFT JOIN departments AS d
ON e.department_id = d.department_id
ORDER BY e.employee_id, d.department_row_id;| employee_id | name | employee_department_id | department_row_id | matched_department_id | department_name |
|---|---|---|---|---|---|
| 1 | Alice | 10 | 101 | 10 | HR |
| 2 | Bob | NULL | NULL | NULL | NULL |
| 3 | Charlie | 20 | 102 | 20 | IT |
Bobは左側の行なので残りますが、Unknown部門に結合したわけではありません。department_row_idもNULLであることが、その違いを示します。左側の行が保持されるのは結合の段階であり、後からWHERE条件などで除外されることはあります。
RIGHT JOINにおけるNULL値の扱い
RIGHT JOINはLEFT JOINの左右を入れ替えた考え方です。一致しない右側の行を残し、左側の列をNULLで補います。この例ではBobではなくUnknown部門の行が残ります。
SELECT e.employee_id, e.name,
e.department_id AS employee_department_id,
d.department_row_id,
d.department_id AS matched_department_id,
d.department_name
FROM employees AS e
RIGHT JOIN departments AS d
ON e.department_id = d.department_id
ORDER BY d.department_row_id, e.employee_id;| employee_id | name | employee_department_id | department_row_id | matched_department_id | department_name |
|---|---|---|---|---|---|
| 1 | Alice | 10 | 101 | 10 | HR |
| 3 | Charlie | 20 | 102 | 20 | IT |
| NULL | NULL | NULL | 103 | NULL | Unknown |
同じ列を選ぶなら、departments AS d LEFT JOIN employees AS eと左右を入れ替えても表現できます。SQLiteのRIGHT JOINとFULL OUTER JOINは3.39.0で追加されました。古いSQLiteではこの構文をそのまま実行できません。
FULL OUTER JOINにおけるNULL値の扱い
FULL OUTER JOINは一致した組に加え、左右それぞれの未結合行を残します。BobのNULLとUnknown部門のNULLは=では一致しないため、1行にまとまらず、別々の未結合行として結果に現れます。
SELECT e.employee_id, e.name,
e.department_id AS employee_department_id,
d.department_row_id,
d.department_id AS matched_department_id,
d.department_name
FROM employees AS e
FULL OUTER JOIN departments AS d
ON e.department_id = d.department_id
ORDER BY CASE WHEN e.employee_id IS NULL THEN 1 ELSE 0 END,
e.employee_id, d.department_row_id;| employee_id | name | employee_department_id | department_row_id | matched_department_id | department_name |
|---|---|---|---|---|---|
| 1 | Alice | 10 | 101 | 10 | HR |
| 2 | Bob | NULL | NULL | NULL | NULL |
| 3 | Charlie | 20 | 102 | 20 | IT |
| NULL | NULL | NULL | 103 | NULL | Unknown |
上の結果は4行です。NULLの並びだけを見て元データに存在したNULLと外部結合が補ったNULLを混同しないよう、左右の行IDも確認しましょう。各種結合の定義はPostgreSQLの結合テーブルで確認できます。
NULL値を考慮したJOINの対処法
まず業務上の意味を決めます。所属部門が「不明」である社員と部門を、どちらもNULLだからという理由だけで結び付けてよいとは限りません。全社員を残したいだけならLEFT JOIN、NULLを同じ区分として扱う要件があるならNULL安全な比較を選びます。
NULL同士も一致させる場合
SELECT e.employee_id, e.name, d.department_row_id, d.department_name
FROM employees AS e
JOIN departments AS d
ON e.department_id IS NOT DISTINCT FROM d.department_id
ORDER BY e.employee_id, d.department_row_id;| employee_id | name | department_row_id | department_name |
|---|---|---|---|
| 1 | Alice | 101 | HR |
| 2 | Bob | 103 | Unknown |
| 3 | Charlie | 102 | IT |
IS NOT DISTINCT FROMは両方がNULLならTRUE、片方だけがNULLならFALSEです。これにより、この例ではBobがUnknown部門と結合します。通常の=と異なる業務上のルールを指定したことになります。
DBMS別のNULL安全な比較とFULL OUTER JOIN
| DBMS・対象バージョン | NULL同士を一致とする比較 | FULL OUTER JOIN |
|---|---|---|
| PostgreSQL 18 | a IS NOT DISTINCT FROM b | 対応 |
| MySQL 8.4 | a <=> b | ネイティブ構文なし |
| SQL Server 2022(16.x)以降 | a IS NOT DISTINCT FROM b | 対応 |
| Oracle Database 19c | 下記のIS NULLを組み合わせる条件 | 対応 |
| SQLite 3.39.0以降 | a IS NOT DISTINCT FROM b(列同士ならa IS bも利用可) | 対応 |
ここでの対応は構文の対応状況です。あらゆる結合条件でFULL OUTER JOINが使えるという保証ではありません。製品・バージョンごとの結合条件の制約も確認してください。特にMySQLの<=>を他製品にそのまま移すことはできず、SQL Serverの2022より前の版でIS NOT DISTINCT FROMが使えると考えるのも誤りです。
専用演算子を使わず、NULL同士だけを特別に一致させる条件は次のように書けます。両方が非NULLかの検査も含めることで、片方だけがNULLの場合もFALSEになります。以下のSQLは本記事のSQLite用データでの例であり、Oracle用の表作成スクリプトではありません。
SELECT e.employee_id, e.name, d.department_row_id, d.department_name
FROM employees AS e
JOIN departments AS d
ON (e.department_id IS NOT NULL
AND d.department_id IS NOT NULL
AND e.department_id = d.department_id)
OR (e.department_id IS NULL AND d.department_id IS NULL)
ORDER BY e.employee_id, d.department_row_id;ONで採用される組だけに注目すれば、a = b OR (a IS NULL AND b IS NULL)でも同じ組になります。ただし、この短い式は片方だけがNULLのときUNKNOWNです。NOTで否定するなどの用途にまで、常にTRUE/FALSEを返す比較と同一だと考えないでください。複合キーなら、比較対象の各列についてNULLの扱いを決め、それぞれの条件を括弧でまとめてANDでつなぎます。
COALESCE関数を用いたNULL値の処理
結合キーを0に置換すると誤結合することがある
COALESCEは引数を順に調べ、最初の非NULL値を返します。すべてNULLなら結果もNULLです。しかし、COALESCE(a, 0) = COALESCE(b, 0)はNULL安全な等価比較の一般的な代用にはなりません。実データの0とNULLが同じ値になるからです。次の独立した例で比較できます。
WITH lhs (id, department_id) AS (
VALUES (1, NULL), (2, 0)
), rhs (id, department_id) AS (
VALUES (101, NULL), (102, 0)
)
SELECT l.id AS left_id, r.id AS right_id,
l.department_id AS left_key, r.department_id AS right_key,
l.department_id IS NOT DISTINCT FROM r.department_id AS null_safe_equal
FROM lhs AS l
JOIN rhs AS r
ON COALESCE(l.department_id, 0) = COALESCE(r.department_id, 0)
ORDER BY l.id, r.id;| left_id | right_id | left_key | right_key | null_safe_equal |
|---|---|---|---|---|
| 1 | 101 | NULL | NULL | 1 |
| 1 | 102 | NULL | 0 | 0 |
| 2 | 101 | 0 | NULL | 0 |
| 2 | 102 | 0 | 0 | 1 |
COALESCEを使ったJOINでは4組が結合されますが、NULL安全な比較なら一致するのは2組です。0を-1や空文字に変えるだけでも、その値が将来入れば同じ問題が起きます。代替値を使うなら実データと衝突しない制約、型の整合性、業務上の意味が必要です。単にNULL同士を一致させたい場合は、専用の比較または明示した条件の方が意図を表せます。
表示用の補完と結合条件を分ける
結合ルールは変えず、表示だけを補うならSELECT句でCOALESCEを使えます。数値と文字列を混在させないよう、ここでは部門IDを明示的に文字列へ変換します。CASTの型名はDBMSに応じて変更してください。
SELECT employee_id, name,
COALESCE(CAST(department_id AS TEXT), 'No Department') AS department
FROM employees
ORDER BY employee_id;この例は元のdepartment_idを更新せず、結果の表示だけを変えます。結合後の部門名を一律にCOALESCEで補うと「未結合」と「結合先の部門名自体がNULL」を区別できなくなるため、その区別が必要なら後述の行IDによる判定を使います。COALESCEの定義:SQLiteの組み込み関数。
複数の候補列から最初の値を選ぶ
住所などの候補列にも同じ考え方を使えます。以下は社員テーブルに存在しない列を参照しない、単独で実行できる例です。
WITH contacts (employee_id, address1, address2, address3) AS (
VALUES (1, NULL, 'Tokyo', 'Osaka'),
(2, NULL, NULL, NULL)
)
SELECT employee_id, COALESCE(address1, address2, address3) AS address
FROM contacts
ORDER BY employee_id;employee_idが1ならTokyo、すべてNULLの2ならNULLになります。COALESCEは「空文字を読み飛ばす」関数ではありません。なお、Oracle Database 19cは長さ0の文字列をNULLとして扱うため、空文字の扱いも移植時に確認してください。
IS NULL/IS NOT NULLを用いたNULL値のフィルタリング
元データにNULLがある行を探す場合は、調べたい元テーブルの列に対してIS NULLを使います。次の結果はBobだけです。
SELECT employee_id, name, department_id
FROM employees
WHERE department_id IS NULL
ORDER BY employee_id;反対に、IS NOT NULLでAliceとCharlieを取り出せます。
SELECT employee_id, name, department_id
FROM employees
WHERE department_id IS NOT NULL
ORDER BY employee_id;すでにINNER JOIN ... ON e.department_id = d.department_idで結合した後なら、両方のキーにIS NOT NULL条件を追加しても、この例の結果は変わりません。一方、外部結合へのWHERE条件の追加は、残したかった行を消すことがあります。
CASEで型を揃えて表示する
SELECT employee_id, name,
CASE WHEN department_id IS NULL THEN 'No Department'
ELSE CAST(department_id AS TEXT)
END AS department
FROM employees
ORDER BY employee_id;THENを文字列、ELSEを未変換の整数にすると、DBMSによって型変換エラーなどの原因になります。この例では両方を文字列に揃えています。SQLiteのTEXTは、他製品ではVARCHARなど対象DBMSに合う型に置き換えます。参考:SQL ServerのCASEの戻り値の型。
外部結合ではONとWHEREの位置に注意する
「全社員を残し、有効な部門があれば表示する」なら、部門の条件をONに置きます。
SELECT e.employee_id, e.name, d.department_name
FROM employees AS e
LEFT JOIN departments AS d
ON e.department_id = d.department_id AND d.active = 1
ORDER BY e.employee_id, d.department_row_id;| employee_id | name | department_name |
|---|---|---|
| 1 | Alice | HR |
| 2 | Bob | NULL |
| 3 | Charlie | NULL |
Charlieには部門IDが一致するIT部門がありますが、activeが0なのでON条件を満たさず、右側がNULLになります。次に同じ条件をWHEREへ移すと、結果はAliceだけです。
SELECT e.employee_id, e.name, d.department_name
FROM employees AS e
LEFT JOIN departments AS d
ON e.department_id = d.department_id
WHERE d.active = 1
ORDER BY e.employee_id, d.department_row_id;WHEREは外部結合によるNULL補完の後に評価されます。Bobのd.active = 1はUNKNOWN、CharlieはFALSEなので、どちらも除外されます。ただし、WHEREに右側の列を書くと常に内部結合になる、という意味ではありません。d.department_row_id IS NULLなど、NULLを受け入れる条件なら未結合行を選べます。参考:SQLiteのWHERE処理。
元データのNULLと「未結合」を区別する
未結合の判定には、右テーブルで必ず値が入る列を使います。この例ではdepartment_row_idがNOT NULLの主キーです。次のクエリは、等価比較で結合相手がなかったBobを返します。
SELECT e.employee_id, e.name
FROM employees AS e
LEFT JOIN departments AS d
ON e.department_id = d.department_id
WHERE d.department_row_id IS NULL
ORDER BY e.employee_id;結合条件をNULL安全な比較に変えるとBobも部門103に結合するため、同じ未結合判定で返る行は0件になります。NULLを許す列を未結合の目印にしないことが重要です。
SELECT e.employee_id, e.name, d.department_id, d.department_row_id,
CASE WHEN d.department_row_id IS NULL THEN 'unmatched'
ELSE 'matched'
END AS match_status
FROM employees AS e
LEFT JOIN departments AS d
ON e.department_id IS NOT DISTINCT FROM d.department_id
ORDER BY e.employee_id, d.department_row_id;Bobのd.department_idはNULLのままですが、行IDは103で、状態はmatchedです。ここでWHERE d.department_id IS NULLを未結合判定として使うとBobを誤って拾います。部門名のようにNULLを許す列も同様です。なお、この注意は条件次第です。通常の=だけで比較した右側のキーは、結合できた時点で非NULLなので判定に使えますが、条件が変更されても意味が明確なNOT NULLの行IDを使う方が安全です。
存在しない相手を探すならNOT EXISTSも使える
SELECT e.employee_id, e.name
FROM employees AS e
WHERE NOT EXISTS (
SELECT 1
FROM departments AS d
WHERE e.department_id = d.department_id
)
ORDER BY e.employee_id;この例もBobを返します。NULL同士を一致とみなす要件なら、内側の比較も同じNULL安全な条件に変えます。NOT INは右側にNULLがあるとUNKNOWNになる場合があるため、無条件に同じものへ置き換えないでください。存在判定の仕様:SQLiteのEXISTS。
NULL同士の結合でも重複行は掛け算になる
JOINは同じ値を1行にまとめる処理ではありません。同じ結合キーに左2行、右3行があれば、他の条件がなければ2×3=6組になります。NULL同士を一致させた場合も同様です。次の例は既存テーブルを変更せずに実行できます。
WITH lhs (id, join_key) AS (
VALUES (1, NULL), (2, NULL)
), rhs (id, join_key) AS (
VALUES (101, NULL), (102, NULL), (103, NULL)
)
SELECT l.id AS left_id, r.id AS right_id
FROM lhs AS l
JOIN rhs AS r ON l.join_key IS NOT DISTINCT FROM r.join_key
ORDER BY l.id, r.id;結果は(1,101)、(1,102)、(1,103)、(2,101)、(2,102)、(2,103)の6組です。=ならこの例は0組です。行数が増えたときは、すぐにDISTINCTで隠すのではなく、一意性・集計単位・どの行を結び付けたいかを確認します。NULLを含む列のUNIQUE制約の扱いも製品・設定によって異なるため、NULLが1件しかないとは決めつけないでください。参考:PostgreSQLの一意制約とNULL。
FULL OUTER JOINが使えない場合の考え方
MySQL 8.4のようにFULL OUTER JOIN構文がない場合は、「左外部結合の全結果」と「右側だけに存在する未結合行」をUNION ALLで合わせる方法があります。下の例はSQLiteの同じ検証データで、先ほどのFULL OUTER JOINと同じ4行になることを確認できます。MySQLへ移す場合はテーブル作成や型などを対象環境に合わせてください。
WITH combined AS (
SELECT e.employee_id, e.name,
e.department_id AS employee_department_id,
d.department_row_id,
d.department_id AS matched_department_id,
d.department_name
FROM employees AS e
LEFT JOIN departments AS d
ON e.department_id = d.department_id
UNION ALL
SELECT e.employee_id, e.name,
e.department_id,
d.department_row_id,
d.department_id,
d.department_name
FROM departments AS d
LEFT JOIN employees AS e
ON e.department_id = d.department_id
WHERE e.employee_id IS NULL
)
SELECT employee_id, name, employee_department_id,
department_row_id, matched_department_id, department_name
FROM combined
ORDER BY CASE WHEN employee_id IS NULL THEN 1 ELSE 0 END,
employee_id, department_row_id;後半はNOT NULLの主キーe.employee_idで未結合行だけに限定しています。両方のLEFT JOIN結果をそのままUNION ALLすると一致した組を二重に数えます。逆にUNIONで重複排除すると、本来残すべき同じ値の結果まで消す可能性があります。結合条件を変更するなら両枝で同じ条件を使ってください。
まとめ
NULLを含むJOINでは、次の順で確認すると原因を切り分けやすくなります。
- 通常の等価比較でNULLはFALSEではなくUNKNOWNになることを確認する。
- 未結合行を残したいのか、NULL同士を一致させたいのかを決める。
- 対象DBMSのNULL安全な比較とJOIN構文を使う。COALESCEの代替値との衝突を避ける。
- 外部結合のONとWHEREを区別し、未結合はNOT NULLの行IDまたはNOT EXISTSで判定する。
- NULLだけでなく、相手が存在しない非NULL値や重複キーも含めて結果の組数を確認する。
NULLを無理に別の値へ置き換えるのではなく、データの意味と必要な結果に合う結合条件を選ぶことが、予期しない欠落や重複を防ぐ基本です。



コメント