SQLでNULL値を含むレコードのJOIN時の挙動と対処法

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_nullnot_equals_nullnegated_comparisonis_null
NULLNULLNULL1

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_idnamedepartment_id
1Alice10
2BobNULL
3Charlie20

departmentsテーブル

department_row_iddepartment_iddepartment_nameactive
10110HR1
10220IT0
103NULLUnknown1

表中の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_idnameemployee_department_iddepartment_row_idmatched_department_iddepartment_name
1Alice1010110HR
3Charlie2010220IT

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_idnameemployee_department_iddepartment_row_idmatched_department_iddepartment_name
1Alice1010110HR
2BobNULLNULLNULLNULL
3Charlie2010220IT

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_idnameemployee_department_iddepartment_row_idmatched_department_iddepartment_name
1Alice1010110HR
3Charlie2010220IT
NULLNULLNULL103NULLUnknown

同じ列を選ぶなら、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_idnameemployee_department_iddepartment_row_idmatched_department_iddepartment_name
1Alice1010110HR
2BobNULLNULLNULLNULL
3Charlie2010220IT
NULLNULLNULL103NULLUnknown

上の結果は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_idnamedepartment_row_iddepartment_name
1Alice101HR
2Bob103Unknown
3Charlie102IT

IS NOT DISTINCT FROMは両方がNULLならTRUE、片方だけがNULLならFALSEです。これにより、この例ではBobがUnknown部門と結合します。通常の=と異なる業務上のルールを指定したことになります。

DBMS別のNULL安全な比較とFULL OUTER JOIN

DBMS・対象バージョンNULL同士を一致とする比較FULL OUTER JOIN
PostgreSQL 18a IS NOT DISTINCT FROM b対応
MySQL 8.4a <=> 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_idright_idleft_keyright_keynull_safe_equal
1101NULLNULL1
1102NULL00
21010NULL0
2102001

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_idnamedepartment_name
1AliceHR
2BobNULL
3CharlieNULL

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では、次の順で確認すると原因を切り分けやすくなります。

  1. 通常の等価比較でNULLはFALSEではなくUNKNOWNになることを確認する。
  2. 未結合行を残したいのか、NULL同士を一致させたいのかを決める。
  3. 対象DBMSのNULL安全な比較とJOIN構文を使う。COALESCEの代替値との衝突を避ける。
  4. 外部結合のONとWHEREを区別し、未結合はNOT NULLの行IDまたはNOT EXISTSで判定する。
  5. NULLだけでなく、相手が存在しない非NULL値や重複キーも含めて結果の組数を確認する。

NULLを無理に別の値へ置き換えるのではなく、データの意味と必要な結果に合う結合条件を選ぶことが、予期しない欠落や重複を防ぐ基本です。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次