PostgreSQLでNULL同士は結合できる?正しいJOINの扱い方を解説

目次

はじめに:NULL結合の落とし穴

「PostgreSQLでLEFT JOINをしたのに、期待した行が結合されない…」
そんな経験はありませんか?
原因の多くは 「NULL同士の比較」 にあります。

本記事では、PostgreSQLにおけるNULLの結合の仕組みと対処法 を初心者にもわかりやすく解説します。
この記事を読むことで、NULLを含むテーブル同士のJOINの方法がわかります。

PostgreSQLでNULL同士が結合されない理由

まず大前提として、SQLではNULLは「不明の値」 として扱われます。
そのため、次のような比較は 「真(TRUE)」ではなく「不明(UNKNOWN)」 になります。

SELECT NULL = NULL;
-- 結果:NULL(TRUEやFALSEではない)

つまり、ON a.column = b.column のような条件式では
NULL = NULL は成立しない のです。

この仕様はPostgreSQLに限らず、SQL標準(ANSI SQL)に準拠した共通の動作です。

NULL同士を結合したいときの3つの方法

1. COALESCE関数を使う方法

COALESCEは「NULLなら代替値を返す」関数です。
NULL同士を共通の値(例:空文字や’NA’)に置き換えることで結合が可能になります。

SELECT
    *
FROM
    invoice_header invc_head
LEFT OUTER JOIN customer_branch cstmr_brnch
    ON invc_head.customer_code = cstmr_brnch.customer_code
    AND COALESCE(invc_head.branch_code, 'NA') = COALESCE(cstmr_brnch.branch_code, 'NA')
;

ポイント:

  • ‘NA’のような固定値を指定しておくと、NULL同士を同一視できる
  • ただし、意図しないマッチングが起きないよう注意が必要

2. IS NOT DISTINCT FROMを使う方法

PostgreSQL独自の構文として、IS NOT DISTINCT FROM を使うと
NULL同士も等しいとみなして比較 できます。

SELECT
    *
FROM
    invoice_header invc_head
LEFT OUTER JOIN customer_branch cstmr_brnch
    ON invc_head.customer_code = cstmr_brnch.customer_code
    AND invc_head.branch_code IS NOT DISTINCT FROM cstmr_brnch.branch_code
;

ポイント:

  • = と異なり、NULL = NULL を真(TRUE)として扱う
  • PostgreSQL専用の構文であり、他のDB(MySQL, Oracleなど)では使えない場合がある
  • NULL以外の同値(例:'001'同士)もTRUEとなり、通常の等価比較もそのまま機能する

3. CONCAT / CONCAT_WSを使った結合

CONCATは「複数の文字列を結合する」関数です。
結合条件を1つの文字列にまとめることで結合が可能となります。

SELECT
    *
FROM
    invoice_header invc_head
LEFT OUTER JOIN customer_branch cstmr_brnch
    ON CONCAT(invc_head.customer_code,invc_head.branch_code)
       = CONCAT(cstmr_brnch.customer_code,cstmr_brnch.branch_code)

ポイント:

  • CONCATCONCAT_WSを使うとNULLを無視して文字列を生成できる
  • 比較条件を単一の文字列化にまとめたい場合に有効

注意:||演算子を使った結合は利用できない

|| 演算子を使うと次のような動作になります。

SELECT 'A' || NULL;
  -- 結果:NULL(全体がNULLになる)

1つでもNULLが含まれると全体がNULLになってしまいます。そのため必須項目ではない項目を結合の利用には適していません。

パフォーマンス面のポイント

インデックス利用をする場合、IS NOT DISTINCT FROMCOALESCEのどちらかが適しています。どちらが高速かは、データ量・NULLの割合・インデックス設計・クエリ内容によって変わるため、実際のクエリに対して実行計画(EXPLAIN ANALYZE)を使って検証しましょう。

インデックス設計や実行計画(EXPLAIN ANALYZE)についてはこちらの記事もおすすめです。

検証:顧客支店マスタと請求データのNULL結合

顧客支店マスタ、請求テーブルをcustomer_code(必須項目)とbranch_code(NULL許可項目)で結合し、INDEXの利用を確認します。

準備:テーブル生成・データ投入・INDEX生成

STEP
テーブル生成

顧客マスタ、顧客支店マスタ、請求テーブルを生成

-- 顧客
CREATE TABLE customer( 
    customer_no bigserial PRIMARY KEY
    , customer_code varchar (10) NOT NULL UNIQUE
    , customer_name text NOT NULL
); 

-- 顧客支店支店(branch_codeはNULL許可)
CREATE TABLE customer_branch( 
    customer_code varchar (10) NOT NULL
    , branch_code varchar (10) -- ← NULL可(本社)
    , branch_name text NOT NULL
); 

-- 請求(branch_codeはNULL可)
CREATE TABLE invoice_header( 
    invoice_id bigserial PRIMARY KEY
    , invoice_date date NOT NULL
    , customer_code varchar (10) NOT NULL
    , branch_code varchar (10)
    , total_amount numeric (12, 2) NOT NULL
    , description text
); 
STEP
データ投入

顧客マスタ、顧客支店マスタ、請求テーブルにデータ投入

-- 顧客(合計10万件)
WITH params AS (SELECT 100000 ::int AS customers) -- 顧客
INSERT 
INTO customer(customer_code, customer_name) 
SELECT
    'C' || lpad(gs ::text, 6, '0')
    , '顧客_' || gs ::text 
FROM
    params p
    , generate_series(1, (SELECT customers FROM params)) AS gs; 

-- 各顧客に「NULL支店」+「1〜5支店」(合計60万件)
INSERT 
INTO customer_branch(customer_code, branch_code, branch_name) 
SELECT
    c.customer_code
    , x.branch_code
    , x.branch_name 
FROM
    customer c 
    CROSS JOIN LATERAL ( 
        -- 本社(NULL)
        SELECT
            NULL ::varchar (10) AS branch_code
            , '本社' AS branch_name 
        UNION ALL                               -- 1〜5支店(顧客ごとに件数を決定)
        SELECT
            'B' || lpad(gs ::text, 3, '0') AS branch_code
            , '支店_' || gs ::text AS branch_name 
        FROM
            generate_series(1, 5) gs
    ) AS x; 

-- 各支店に対して5件ずつ請求を作成(NULL支店も含む)(合計300万件)
INSERT 
INTO invoice_header( 
    invoice_date
    , customer_code
    , branch_code
    , total_amount
    , description
) 
SELECT
    (DATE '2024-01-01' + ((i % 365)) ::int) AS invoice_date
    , b.customer_code
    , b.branch_code
    , round(100 + (i * 7 % 900) ::numeric, 2) AS total_amount
    , '請求_' || b.customer_code || '_' || COALESCE(b.branch_code, 'NULL') || '_' || i 
FROM
    customer_branch b 
    CROSS JOIN generate_series(1, 5) AS i; 
STEP
インデックス生成

結合で利用する請求テーブルのcustomer_code(必須項目)とbranch_code(NULL許可項目)にINDEXを生成する

CREATE INDEX idx_invoice_customer_branch ON invoice_header(customer_code, branch_code); 

1. COALESCE関数を使う方法

NULL許可項目であるbranch_codeに対して、COALESCE関数を使います。

実行計画

-- COALESCE関数を使う方法(INDEX効く)
EXPLAIN ANALYZE 
SELECT
    *
FROM
    invoice_header invc_head
LEFT OUTER JOIN customer_branch cstmr_brnch
    ON invc_head.customer_code = cstmr_brnch.customer_code
    AND COALESCE(invc_head.branch_code, 'NA') = COALESCE(cstmr_brnch.branch_code, 'NA')
WHERE
    cstmr_brnch.customer_code = 'C100000'
ORDER BY
    invc_head.invoice_id
;

実行結果を確認

Gather Merge  (cost=7978.81..7978.93 rows=1 width=73) (actual time=162.937..168.174 rows=30 loops=1)
  Workers Planned: 2
  Workers Launched: 2
  ->  Sort  (cost=6978.78..6978.79 rows=1 width=73) (actual time=20.683..20.686 rows=10 loops=3)
        Sort Key: invc_head.invoice_id
        Sort Method: quicksort  Memory: 27kB
        Worker 0:  Sort Method: quicksort  Memory: 25kB
        Worker 1:  Sort Method: quicksort  Memory: 25kB
        ->  Hash Join  (cost=126.75..6978.77 rows=1 width=73) (actual time=20.600..20.605 rows=10 loops=3)
              Hash Cond: ((COALESCE(cstmr_brnch.branch_code, 'NA'::character varying))::text = (COALESCE(invc_head.branch_code, 'NA'::character varying))::text)
              ->  Parallel Seq Scan on customer_branch cstmr_brnch  (cost=0.00..6852.00 rows=2 width=21) (actual time=20.568..20.569 rows=2 loops=3)
                    Filter: ((customer_code)::text = 'C100000'::text)
                    Rows Removed by Filter: 199998
              ->  Hash  (cost=126.36..126.36 rows=31 width=52) (actual time=0.072..0.074 rows=30 loops=1)
                    Buckets: 1024  Batches: 1  Memory Usage: 11kB
                    ->  Index Scan using idx_invoice_customer_branch on invoice_header invc_head  (cost=0.43..126.36 rows=31 width=52) (actual time=0.047..0.062 rows=30 loops=1)
                          Index Cond: ((customer_code)::text = 'C100000'::text)
Planning Time: 0.268 ms
Execution Time: 168.231 ms

出力結果にIndex Scan using idx_invoice_customer_branchが表示されておりインデックスが活用されています。
実行時間は168.174ミリ秒。

2. IS NOT DISTINCT FROMを使う方法

NULL許可項目であるbranch_codeに対して、IS NOT DISTINCT FROMを使います。

実行計画

-- IS NOT DISTINCT FROMを使う方法(INDEX効く)
EXPLAIN ANALYZE 
SELECT
    *
FROM
    invoice_header invc_head
LEFT OUTER JOIN customer_branch cstmr_brnch
    ON invc_head.customer_code = cstmr_brnch.customer_code
    AND invc_head.branch_code IS NOT DISTINCT FROM cstmr_brnch.branch_code
WHERE
    cstmr_brnch.customer_code = 'C100000'
ORDER BY
    invc_head.invoice_id
;

実行結果を確認

Sort  (cost=7982.38..7982.44 rows=26 width=73) (actual time=60.426..65.525 rows=30 loops=1)
  Sort Key: invc_head.invoice_id
  Sort Method: quicksort  Memory: 27kB
  ->  Nested Loop  (cost=1000.43..7981.77 rows=26 width=73) (actual time=59.206..65.490 rows=30 loops=1)
        Join Filter: (NOT ((invc_head.branch_code)::text IS DISTINCT FROM (cstmr_brnch.branch_code)::text))
        Rows Removed by Join Filter: 150
        ->  Index Scan using idx_invoice_customer_branch on invoice_header invc_head  (cost=0.43..126.36 rows=31 width=52) (actual time=0.067..0.099 rows=30 loops=1)
              Index Cond: ((customer_code)::text = 'C100000'::text)
        ->  Materialize  (cost=1000.00..7852.63 rows=6 width=21) (actual time=1.971..2.178 rows=6 loops=30)
              ->  Gather  (cost=1000.00..7852.60 rows=6 width=21) (actual time=59.127..65.318 rows=6 loops=1)
                    Workers Planned: 2
                    Workers Launched: 2
                    ->  Parallel Seq Scan on customer_branch cstmr_brnch  (cost=0.00..6852.00 rows=2 width=21) (actual time=20.786..20.787 rows=2 loops=3)
                          Filter: ((customer_code)::text = 'C100000'::text)
                          Rows Removed by Filter: 199998
Planning Time: 0.228 ms
Execution Time: 65.570 ms

出力結果にIndex Scan using idx_invoice_customer_branchが表示されておりインデックスが活用されています。
実行時間は65.525ミリ秒。

3. CONCATを使った結合

customer_code(必須項目)とbranch_code(NULL許可項目)に対して、CONCATを使います。

実行計画

-- CONCAT / CONCAT_WSを使った結合(INDEX効かない)
EXPLAIN ANALYZE 
SELECT
    *
FROM
    invoice_header invc_head
LEFT OUTER JOIN customer_branch cstmr_brnch
    ON CONCAT(invc_head.customer_code,invc_head.branch_code) = CONCAT(cstmr_brnch.customer_code,cstmr_brnch.branch_code)
WHERE
    cstmr_brnch.customer_code = 'C100000'
ORDER BY
    invc_head.invoice_id
;

実行結果を確認

Gather Merge  (cost=61751.54..70502.15 rows=75000 width=73) (actual time=529.382..538.277 rows=30 loops=1)
  Workers Planned: 2
  Workers Launched: 2
  ->  Sort  (cost=60751.51..60845.26 rows=37500 width=73) (actual time=489.182..489.185 rows=10 loops=3)
        Sort Key: invc_head.invoice_id
        Sort Method: quicksort  Memory: 25kB
        Worker 0:  Sort Method: quicksort  Memory: 26kB
        Worker 1:  Sort Method: quicksort  Memory: 25kB
        ->  Parallel Hash Join  (cost=6852.02..57902.53 rows=37500 width=73) (actual time=268.522..489.033 rows=10 loops=3)
              Hash Cond: (concat(invc_head.customer_code, invc_head.branch_code) = concat(cstmr_brnch.customer_code, cstmr_brnch.branch_code))
              ->  Parallel Seq Scan on invoice_header invc_head  (cost=0.00..43113.00 rows=1250000 width=52) (actual time=0.390..105.193 rows=1000000 loops=3)
              ->  Parallel Hash  (cost=6852.00..6852.00 rows=2 width=21) (actual time=23.143..23.143 rows=2 loops=3)
                    Buckets: 1024  Batches: 1  Memory Usage: 40kB
                    ->  Parallel Seq Scan on customer_branch cstmr_brnch  (cost=0.00..6852.00 rows=2 width=21) (actual time=34.642..34.643 rows=3 loops=2)
                          Filter: ((customer_code)::text = 'C100000'::text)
                          Rows Removed by Filter: 299997
Planning Time: 0.210 ms
Execution Time: 538.327 ms

出力結果にParallel Seq Scan on project_members(テーブル全体を並列処理で読み込み)が表示されており、INDEXが活用されていません。
実行時間は538.277ミリ秒。

まとめ:NULLを理解すればJOINは怖くない

PostgreSQLでNULL同士が結合されないのは、仕様に基づいた正しい動作です。
しかし、要件によってはNULL同士を「同じ」とみなす必要もあります。

まとめ:

方法特徴備考
COALESCENULLを代替値で置換他DBでも使える。INDEX利用可。
IS NOT DISTINCT FROMNULL同士を等価とするPostgreSQL独自。INDEX利用可。
CONCAT結合した文字列で比較運用方針次第。INDEX利用不可。

NULLの仕組みを理解し、状況に応じた対策を取ることで、より安全で正確なSQLが書けるようになります。

DB関連おすすめ書籍

基礎から実務レベルまで体系的に学べる“DBエンジニア必携の2冊”として特におすすめです。

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

この記事を書いた人

・20代
・IT未経験からSIerに就職、開発とプロジェクト管理を経験
・働いている中で会計に興味を持ち、ERPの会計領域を挑戦中
・筋トレ最高!
保有資格:日商簿記1級/応用情報/ITストラテジスト

コメント

コメントする

目次