はじめに: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)ポイント:
CONCATやCONCAT_WSを使うとNULLを無視して文字列を生成できる- 比較条件を単一の文字列化にまとめたい場合に有効
注意:||演算子を使った結合は利用できない
|| 演算子を使うと次のような動作になります。
SELECT 'A' || NULL;
-- 結果:NULL(全体がNULLになる)1つでもNULLが含まれると全体がNULLになってしまいます。そのため必須項目ではない項目を結合の利用には適していません。
パフォーマンス面のポイント
インデックス利用をする場合、IS NOT DISTINCT FROMかCOALESCEのどちらかが適しています。どちらが高速かは、データ量・NULLの割合・インデックス設計・クエリ内容によって変わるため、実際のクエリに対して実行計画(EXPLAIN ANALYZE)を使って検証しましょう。
インデックス設計や実行計画(EXPLAIN ANALYZE)についてはこちらの記事もおすすめです。
検証:顧客支店マスタと請求データのNULL結合
顧客支店マスタ、請求テーブルをcustomer_code(必須項目)とbranch_code(NULL許可項目)で結合し、INDEXの利用を確認します。
準備:テーブル生成・データ投入・INDEX生成
顧客マスタ、顧客支店マスタ、請求テーブルを生成
-- 顧客
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
); 顧客マスタ、顧客支店マスタ、請求テーブルにデータ投入
-- 顧客(合計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; 結合で利用する請求テーブルの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同士を「同じ」とみなす必要もあります。
まとめ:
| 方法 | 特徴 | 備考 |
|---|---|---|
| COALESCE | NULLを代替値で置換 | 他DBでも使える。INDEX利用可。 |
| IS NOT DISTINCT FROM | NULL同士を等価とする | PostgreSQL独自。INDEX利用可。 |
| CONCAT | 結合した文字列で比較 | 運用方針次第。INDEX利用不可。 |
NULLの仕組みを理解し、状況に応じた対策を取ることで、より安全で正確なSQLが書けるようになります。
DB関連おすすめ書籍
基礎から実務レベルまで体系的に学べる“DBエンジニア必携の2冊”として特におすすめです。



コメント