PostgreSQL NULL 判定方法

はい、承知いたしました。PostgreSQLにおけるNULL判定方法の詳細な説明を含む記事を作成します。


PostgreSQLにおけるNULL判定方法の徹底解説

はじめに:NULLとは何か?なぜ特別なのか?

リレーショナルデータベースにおいて、NULLは非常に重要な概念です。データが存在しないこと、未知であること、あるいは該当しないことを示すために使用されます。しかし、多くのデータベース初心者、あるいは経験者であっても、NULLの扱い、特にその「判定」に関して誤解しているケースが少なくありません。なぜなら、NULLは他の値とは根本的に異なる性質を持つからです。

一般的なプログラミング言語における変数の「未初期化」や、空文字列、数値のゼロとは異なり、データベースのNULLは「値がない」という状態そのものを表します。これは、NULL同士を比較しても等しいとはみなされないという独特の振る舞いにつながります。例えば、あるカラムがNULLである行が2つあったとしても、その2つのNULLが「等しい」と判断されることはありません。これは、「未知」なもの同士を比較しても、それが同じ「未知」なのか異なる「未知」なのか判断できない、という哲学に基づいています。

PostgreSQLを含め、多くのSQLデータベースシステムでは、NULLは「3値論理 (Three-Valued Logic, 3VL)」という体系に基づいて評価されます。通常の論理はTRUEとFALSEの2値ですが、3値論理ではこれにUNKNOWN(不明)が加わります。NULLを含む比較や演算の結果は、多くの場合、このUNKNOWNとなります。そして、SQLのWHERE句は、評価結果がTRUEとなる行のみを返します。UNKNOWNとなる行は、FALSEと同様にフィルタリングされてしまいます。この挙動が、NULLを適切に判定しないと意図しない結果になる原因となります。

本記事では、PostgreSQLにおけるNULLの判定方法に焦点を当て、その基本的な方法から、NULLが各種SQL演算や関数、制約、インデックスにどのように影響するか、さらには性能に関する考慮事項やベストプラクティスまで、詳細かつ網羅的に解説します。約5000語を目標に、理論的な背景と実践的なコード例を豊富に盛り込み、NULLの扱いに習熟するための決定版となるような情報を提供します。

1. NULLの基本判定方法:IS NULLIS NOT NULL

NULLを判定する上で、最も重要かつ基本的な方法は IS NULLIS NOT NULL です。これはSQL標準で定義されており、PostgreSQLでも推奨される唯一の方法です。

1.1. なぜ = NULL<> NULL を使ってはいけないのか?

多くの初心者が陥りがちな誤りは、通常の比較演算子 (=, <>, >, <, >=, <=) を使ってNULLを判定しようとすることです。例えば、以下のようなクエリを考えてみましょう。

“`sql
— 間違い!
SELECT *
FROM your_table
WHERE nullable_column = NULL;

— 間違い!
SELECT *
FROM your_table
WHERE nullable_column <> NULL;
“`

これらのクエリは、期待通りの結果を返しません。nullable_columnが実際にNULLである行があったとしても、WHERE nullable_column = NULLという条件はTRUEにはならず、UNKNOWNと評価されます。前述の3値論理の規則により、UNKNOWNと評価された行はWHERE句によってフィルタリングされてしまうため、結果として何も返されないか、意図しない行がフィルタリングされることになります。

同様に、nullable_column <> NULLも、nullable_columnが非NULL値であってもNULLであっても、評価結果はUNKNOWNとなることがほとんどです(非NULL値とNULLの不等比較もUNKNOWNです)。したがって、この条件もまた期待通りに機能しません。

この挙動は、NULLが「値」ではなく「値がない状態」を表すことから来ています。「未知」であるNULLと、「未知」であるNULLや具体的な値を比較しても、その比較がTRUEなのかFALSEなのか判断できないため、結果はUNKNOWNにならざるを得ないのです。

1.2. 正しいNULL判定方法:IS NULLIS NOT NULL

NULLを正しく判定するためには、専用の述語である IS NULL および IS NOT NULL を使用する必要があります。

  • IS NULL: 対象の式がNULLであればTRUEを返します。NULLでなければFALSEを返します。
  • IS NOT NULL: 対象の式がNULLでなければTRUEを返します。NULLであればFALSEを返します。

これらの述語は、3値論理のUNKNOWNを生成することなく、常にTRUEまたはFALSEのどちらかを返します。

例を見てみましょう。まず、検証用のテーブルを作成します。

“`sql
CREATE TABLE products (
product_id SERIAL PRIMARY KEY,
product_name VARCHAR(100) NOT NULL,
price DECIMAL(10, 2),
description TEXT
);

INSERT INTO products (product_name, price, description) VALUES
(‘Laptop’, 1200.00, ‘Powerful laptop’),
(‘Keyboard’, 75.00, NULL), — description is NULL
(‘Mouse’, NULL, ‘Wireless mouse’), — price is NULL
(‘Monitor’, 300.00, ‘4K display’),
(‘Webcam’, NULL, NULL); — price and description are NULL
“`

descriptionがNULLの製品を検索するには、以下のようにします。

sql
-- descriptionがNULLの製品を検索
SELECT product_name, price, description
FROM products
WHERE description IS NULL;

出力:
product_name | price | description
-------------+-------+-------------
Keyboard | 75.00 | -- NULLは通常、表示時に空に見えます
Webcam | |

priceがNULLでない製品を検索するには、以下のようにします。

sql
-- priceがNULLでない製品を検索
SELECT product_name, price, description
FROM products
WHERE price IS NOT NULL;

出力:
product_name | price | description
-------------+--------+-------------
Laptop | 1200.00 | Powerful laptop
Keyboard | 75.00 |
Monitor | 300.00 | 4K display

このように、IS NULLIS NOT NULLは、NULLを正確に判定するための基本的な、そして最も重要なツールです。これらの述語を使うことが、PostgreSQL(および他のSQLデータベース)でNULLを扱う際の最初のステップです。

1.3. IS DISTINCT FROMIS NOT DISTINCT FROM

SQL標準には、NULLを含む2つの値を比較するためのもう一組の述語 IS DISTINCT FROM および IS NOT DISTINCT FROM があります。これらは、通常の比較演算子とは異なり、NULLを「既知の値」のように扱って比較を行い、UNKNOWNを返しません。

  • value1 IS DISTINCT FROM value2: value1value2 が異なる場合にTRUEを返します。

    • NULL IS DISTINCT FROM NULL は FALSE (NULLは別のNULLと区別できない、という意味合い)
    • NULL IS DISTINCT FROM non-NULL は TRUE
    • non-NULL IS DISTINCT FROM non-NULL は通常通り評価
  • value1 IS NOT DISTINCT FROM value2: value1value2 が等しいか、あるいは両方ともNULLである場合にTRUEを返します。

    • NULL IS NOT DISTINCT FROM NULL は TRUE
    • NULL IS NOT DISTINCT FROM non-NULL は FALSE
    • non-NULL IS NOT DISTINCT FROM non-NULL は通常通り評価

これは、value1 = value2 OR (value1 IS NULL AND value2 IS NULL) のような論理とほぼ同等ですが、より簡潔に記述できます。

例:価格が300.00と異なる製品を検索(NULL価格を含むかどうかで結果が変わる)

sql
-- priceが300.00と異なる(またはpriceがNULL)製品
-- 通常の <> は price が NULL の行を返さない
SELECT product_name, price
FROM products
WHERE price <> 300.00;

出力:
product_name | price
-------------+--------
Laptop | 1200.00
Keyboard | 75.00

MouseWebcamは価格がNULLのため含まれない)

sql
-- priceが300.00とIS DISTINCT FROMである製品
-- price が NULL の行も含まれる (NULL は 300.00 と DISTINCT FROM であるとみなされるため)
SELECT product_name, price
FROM products
WHERE price IS DISTINCT FROM 300.00;

出力:
product_name | price
-------------+--------
Laptop | 1200.00
Keyboard | 75.00
Mouse | -- price is NULL
Webcam | -- price is NULL

IS DISTINCT FROMIS NOT DISTINCT FROM は、NULLを値のように扱って比較したい場合に便利ですが、最も一般的なNULL判定はやはり IS NULLIS NOT NULL です。状況に応じて適切な述語を選択することが重要です。

2. NULLがSQLの各種演算・関数に与える影響

NULLの特殊性は、単なる判定方法にとどまりません。各種のSQL演算、関数、句においても独特の振る舞いを示します。これらの挙動を理解することは、NULLを正しく扱う上で不可欠です。

2.1. 演算子 (算術、文字列連結など)

多くの演算子において、オペランドのいずれかがNULLであれば、結果はNULLになります。

  • 算術演算子 (+, -, *, /, %):
    5 + NULL -> NULL
    NULL * 10 -> NULL
    10 / NULL -> NULL
    NULLを含む算術演算の結果はすべてNULLになります。これは、未知の値との演算結果は未知になる、という考え方に基づきます。

  • 文字列連結演算子 (||):
    PostgreSQLにおける文字列連結も同様に、オペランドのいずれかがNULLであれば結果はNULLになります。
    'Hello' || NULL -> NULL
    NULL || 'World' -> NULL
    'Hello' || ' ' || NULL || '!' -> NULL
    もしNULLを空文字列として扱って連結したい場合は、後述するCOALESCE関数などを使う必要があります。

2.2. 関数

多くのスカラー関数(単一の行に対して値を返す関数)も、引数のいずれかがNULLであれば結果としてNULLを返します。

  • UPPER(NULL) -> NULL
  • LOWER(NULL) -> NULL
  • SUBSTRING('abc', 1, NULL) -> NULL
  • SQRT(NULL) -> NULL
  • 日付/時刻関数なども同様です。DATE_PART('year', NULL) -> NULL

ただし、一部の関数はNULLを特別に扱います。最も代表的なのが、NULL処理のために設計された関数群です(後述のCOALESCE, NULLIFなど)。また、特定の関数は仕様上NULLを無視したり、NULLに対して特別な値を返したりすることがあります。関数のドキュメントを確認することが重要です。

2.3. 集計関数 (Aggregate Functions)

集計関数(SUM, AVG, COUNT, MIN, MAXなど)は、NULLを扱う際にスカラー関数とは異なる、非常に重要な挙動を示します。多くの集計関数は、計算対象の行にNULL値が含まれていても、そのNULL値を単純に無視します。

  • COUNT(column_name): 特定のカラムについてNULLでない値の数をカウントします。
    COUNT(*) はNULLを含むすべての行の数をカウントします。
    例: SELECT COUNT(price), COUNT(description), COUNT(*) FROM products;
    出力 (productsテーブルのデータに基づく):
    count | count | count
    ------+-------+------
    3 | 3 | 5
    (priceがNULLでないのは3行、descriptionがNULLでないのは3行、全行は5行)

  • SUM(column_name): NULL以外の値の合計を計算します。NULL値は合計に含められません。
    例: SELECT SUM(price) FROM products;
    価格は1200.00, 75.00, NULL, 300.00, NULL なので、NULLを無視して合計は 1200.00 + 75.00 + 300.00 = 1575.00 となります。

  • AVG(column_name): NULL以外の値の平均を計算します。これは SUM(column_name) / COUNT(column_name) として計算されるのが一般的です(ただし、COUNT(column_name)がゼロの場合はNULLを返します)。つまり、平均の計算においては、分母となる要素数にNULLの行は含まれません。
    例: SELECT AVG(price) FROM products;
    合計は1575.00、NULLでない要素数は3なので、平均は 1575.00 / 3 = 525.00 となります。
    もしNULLをゼロとして扱いたい場合は、AVG(COALESCE(price, 0)) のように COALESCE を使用する必要があります。この場合、NULLも計算対象に含まれることになり、分母はCOUNT(*)またはテーブルの行数に近い値になります。

  • MIN(column_name) / MAX(column_name): NULL以外の値の中での最小値/最大値を返します。NULLは無視されます。対象となる非NULL値がない場合はNULLを返します。

集計関数におけるNULLの無視は、しばしばNULLを「欠損値」として扱い、存在するデータのみで統計量を計算したい場合に便利なデフォルトの挙動です。しかし、NULLを特定の意味(例えば0)として扱って集計に含めたい場合は、COALESCEなどの関数を使って明示的にNULLを別の値に変換する必要があります。

2.4. ORDER BY

ORDER BY 句におけるNULLのソート順は、データベースシステムによって、あるいは設定によって異なります。PostgreSQLのデフォルトの挙動は以下の通りです。

  • ASC (昇順): NULLは非NULL値のに配置されます。
  • DESC (降順): NULLは非NULL値のに配置されます。

このデフォルト順序は、NULLS FIRST または NULLS LAST という修飾子を使って明示的に指定・変更できます。

例: 製品を価格で昇順ソート

sql
SELECT product_name, price
FROM products
ORDER BY price ASC; -- または単に ORDER BY price

出力:
product_name | price
-------------+--------
Keyboard | 75.00
Monitor | 300.00
Laptop | 1200.00
Mouse | -- NULLs come last in ASC
Webcam | -- NULLs come last in ASC

例: 製品を価格で降順ソート

sql
SELECT product_name, price
FROM products
ORDER BY price DESC;

出力:
product_name | price
-------------+--------
Mouse | -- NULLs come first in DESC
Webcam | -- NULLs come first in DESC
Laptop | 1200.00
Monitor | 300.00
Keyboard | 75.00

例: 価格を昇順、NULLを先頭に配置

sql
SELECT product_name, price
FROM products
ORDER BY price ASC NULLS FIRST;

出力:
product_name | price
-------------+--------
Mouse | -- NULLs explicitly first
Webcam | -- NULLs explicitly first
Keyboard | 75.00
Monitor | 300.00
Laptop | 1200.00

ソート順にNULLの配置を明示的に指定することは、クエリの可読性を高め、異なる環境間での挙動の差異を防ぐ上で良いプラクティスです。

3. NULLの高度な処理と変換関数

NULLを判定するだけでなく、NULLを他の値に置き換えたり、条件に基づいてNULLを生成したりするのに役立つ関数がいくつかあります。

3.1. COALESCE 関数

COALESCE はおそらく最もよく使われるNULL関連の関数です。複数の引数を取り、リストの中で最初にNULLでない値を返します。すべての引数がNULLの場合はNULLを返します。

構文: COALESCE(value1, value2, value3, ...)

これは、NULLの代わりにデフォルト値を表示したい場合や、複数の可能性のあるカラムから最初に見つかった非NULL値を取得したい場合に非常に便利です。

例1: 価格がNULLの場合に「価格未定」と表示する

sql
SELECT
product_name,
COALESCE(price::TEXT, '価格未定') AS display_price
FROM products;

(注意: priceは数値型なので、テキストにキャスト(::TEXT)しています。COALESCEの引数は互換性のある型である必要があります。)

出力:
product_name | display_price
-------------+---------------
Laptop | 1200.00
Keyboard | 75.00
Mouse | 価格未定
Monitor | 300.00
Webcam | 価格未定

例2: 製品の説明がNULLの場合、代わりに製品名を使用する

sql
SELECT
product_name,
COALESCE(description, product_name) AS effective_description
FROM products;

出力:
product_name | effective_description
-------------+-----------------------
Laptop | Powerful laptop
Keyboard | Keyboard -- description was NULL, using product_name
Mouse | Wireless mouse
Monitor | 4K display
Webcam | Webcam -- description was NULL, using product_name

COALESCE は非常に柔軟性が高く、引数としてカラム、リテラル、関数呼び出しなど、評価可能な式であれば何でも指定できます。

3.2. NULLIF 関数

NULLIF 関数は、2つの引数を取り、もし2つの引数が等しければNULLを返し、等しくなければ最初の引数の値を返します

構文: NULLIF(value1, value2)

これは、特定の値(例えば、アンケートの回答で「該当なし」を意味する特殊なコード、あるいは計算におけるゼロなど)をNULLとして扱いたい場合に便利です。

例1: アンケートで「0」が「無回答」を意味する場合、これをNULLとして扱う

“`sql
— 例データ: アンケートの評価 (1-5, 0は無回答)
CREATE TEMPORARY TABLE survey_results (
respondent_id SERIAL PRIMARY KEY,
rating INTEGER
);

INSERT INTO survey_results (rating) VALUES (5), (4), (0), (3), (0), (5), (4);

— 評価が0のものをNULLに変換して表示
SELECT respondent_id, NULLIF(rating, 0) AS effective_rating
FROM survey_results;
“`

出力:
respondent_id | effective_rating
--------------+------------------
1 | 5
2 | 4
3 | -- rating was 0, now NULL
4 | 3
5 | -- rating was 0, now NULL
6 | 5
7 | 4

例2: ゼロ除算を防ぐ

もし denominator_column がゼロになる可能性がある場合、単純な numerator_column / denominator_column はゼロ除算エラーを引き起こす可能性があります。NULLIF を使って、分母がゼロの場合はNULLに変換することで、結果がNULLになりますがエラーは回避できます。

sql
SELECT numerator_column / NULLIF(denominator_column, 0) AS result
FROM some_table;

denominator_column が0の場合、NULLIF(denominator_column, 0) はNULLを返します。すると、numerator_column / NULL という計算になり、結果はNULLとなります。

3.3. GREATEST および LEAST 関数

GREATEST は引数リストの中から最大値を、LEAST は最小値を返します。ただし、これらの関数は引数リストのいずれかにNULLが含まれている場合、結果としてNULLを返します

構文: GREATEST(value1, value2, ...) および LEAST(value1, value2, ...)

これは集計関数の MAXMIN がNULLを無視するのとは対照的な挙動です。もしNULLを無視して最大/最小を求めたい場合は、集計関数を使うか、COALESCEと組み合わせてNULLを特定のデフォルト値(例えば非常に小さい/大きい値)に変換してからGREATEST/LEASTを使う必要があります。

例: 複数の列の値の中から最大値を取得 (NULLを含む可能性あり)

“`sql
CREATE TEMPORARY TABLE sales_data (
sale_id SERIAL PRIMARY KEY,
q1_sales DECIMAL(10, 2),
q2_sales DECIMAL(10, 2),
q3_sales DECIMAL(10, 2),
q4_sales DECIMAL(10, 2)
);

INSERT INTO sales_data (q1_sales, q2_sales, q3_sales, q4_sales) VALUES
(100, 150, 120, 200),
(50, 70, NULL, 90), — Q3 is NULL
(NULL, 60, 80, 110), — Q1 is NULL
(NULL, NULL, NULL, NULL), — All NULL
(300, 400, 500, 600);
“`

GREATEST を使用:

sql
SELECT
sale_id,
q1_sales, q2_sales, q3_sales, q4_sales,
GREATEST(q1_sales, q2_sales, q3_sales, q4_sales) AS max_sales
FROM sales_data;

出力:
sale_id | q1_sales | q2_sales | q3_sales | q4_sales | max_sales
--------+----------+----------+----------+----------+-----------
1 | 100.00 | 150.00 | 120.00 | 200.00 | 200.00 -- All non-NULL
2 | 50.00 | 70.00 | | 90.00 | -- Q3 is NULL, result is NULL
3 | | 60.00 | 80.00 | 110.00 | -- Q1 is NULL, result is NULL
4 | | | | | -- All NULL, result is NULL
5 | 300.00 | 400.00 | 500.00 | 600.00 | 600.00 -- All non-NULL

NULLを無視して最大値を取得したい場合は、COALESCEと組み合わせるか、あるいはサブクエリや他の手法(例: UNNESTして集計関数を使う)を検討する必要があります。例えば、NULLを0として扱って最大値を取得するなら GREATEST(COALESCE(q1_sales, 0), COALESCE(q2_sales, 0), ...) となります。

3.4. CASE

SQLの CASE 式は、NULLの判定と変換を非常に柔軟に行うための強力なツールです。WHEN expression IS NULL THEN ... ELSE ... END のように、IS NULLIS NOT NULL と組み合わせてNULLを条件分岐の基準として使用できます。

構文:
sql
CASE
WHEN condition1 THEN result1
WHEN condition2 THEN result2
...
ELSE result_else
END

または

sql
CASE expression
WHEN value1 THEN result1
WHEN value2 THEN result2
...
ELSE result_else
END

最初の構文(Searched CASE)は、NULL判定を行う際によく使われます。

例: 製品価格がNULLか、特定の範囲かによって異なる表示を行う

sql
SELECT
product_name,
price,
CASE
WHEN price IS NULL THEN '価格情報なし'
WHEN price < 100.00 THEN '低価格帯'
WHEN price >= 100.00 AND price < 500.00 THEN '中価格帯'
ELSE '高価格帯'
END AS price_category
FROM products;

出力:
product_name | price | price_category
-------------+--------+----------------
Laptop | 1200.00 | 高価格帯
Keyboard | 75.00 | 低価格帯
Mouse | | 価格情報なし
Monitor | 300.00 | 中価格帯
Webcam | | 価格情報なし

CASE 式は、複数の条件を組み合わせたり、NULLを複雑なロジックの一部として扱ったりする場合に非常に有効です。COALESCENULLIF が特定のパターンに特化しているのに対し、CASE はより汎用的なNULL処理ロジックを記述できます。

4. 制約とインデックスにおけるNULL

テーブル定義における制約や、性能向上のためのインデックスも、NULLとどのように相互作用するかを理解しておく必要があります。

4.1. NOT NULL 制約

最も基本的な制約である NOT NULL は、特定カラムにNULL値を格納することを完全に禁止します。これにより、そのカラムには常に何らかの値が存在することが保証されます。これは、データ品質を維持し、後続のクエリやアプリケーションロジックでNULLチェックの手間を省く上で非常に重要です。

例: テーブル作成時にNOT NULLを指定

sql
CREATE TABLE users (
user_id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE, -- username must not be NULL
email VARCHAR(100), -- email can be NULL
registration_date DATE NOT NULL
);

usernameregistration_date にはNOT NULL制約があるため、これらのカラムにNULLを挿入しようとするとエラーになります。

“`sql
— OK
INSERT INTO users (username, email, registration_date) VALUES (‘alice’, ‘[email protected]’, ‘2023-01-01’);
INSERT INTO users (username, email, registration_date) VALUES (‘bob’, NULL, ‘2023-01-02’); — email is NULL, allowed

— ERROR: null value in column “username” violates not-null constraint
INSERT INTO users (username, email, registration_date) VALUES (NULL, ‘[email protected]’, ‘2023-01-03’);

— ERROR: null value in column “registration_date” violates not-null constraint
INSERT INTO users (username, email, registration_date) VALUES (‘david’, ‘[email protected]’, NULL);
“`

どのカラムがNULLを許容するかは、テーブル設計の重要な一部です。必須の情報には必ずNOT NULL制約を付与するべきです。

4.2. 主キー (Primary Key)

主キーは、テーブル内の各行を一意に識別するためのカラムまたはカラムの組み合わせです。SQL標準では、主キーカラムにはNULL値を格納することはできません。PostgreSQLもこの規則に従います。主キーは自動的にNOT NULL制約を持ちます。

これは、NULLが「未知」であるため、一意の識別子としては機能しないからです。複数の行が主キーにNULLを持つ可能性があると、それらの行を互いに区別したり、他のテーブルから参照したりすることが不可能になります。

4.3. 一意制約 (Unique Constraint)

一意制約は、指定されたカラムまたはカラムの組み合わせにおいて、すべての非NULL値が一意であることを保証します。主キーと異なり、一意制約が設定されたカラムはNULLを許容します(ただし、NOT NULL制約が同時に付与されていない場合)。そして、ここでの重要なポイントは、複数の行が同じ一意制約カラムにNULL値を持つことができるという点です。

これは、前述の「NULL同士は等しいとはみなされない」という原則に基づいています。一意制約は「等しい値が複数存在しない」ことを要求しますが、NULLは他のNULLと等しいとはみなされないため、複数のNULLが存在しても一意性違反とはなりません。

例: emailカラムに一意制約を追加

sql
ALTER TABLE users ADD UNIQUE (email);

テーブルには既に bob の行があり、emailはNULLです。

“`sql
— ERROR: duplicate key value violates unique constraint “users_username_key”
— INSERT INTO users (username, email, registration_date) VALUES (‘bob’, ‘[email protected]’, ‘2023-01-05’); — username unique violation

— OK: Alice’s email is unique and non-NULL
INSERT INTO users (username, email, registration_date) VALUES (‘charlie’, ‘[email protected]’, ‘2023-01-03’);

— OK: Another user with NULL email is allowed because NULL <> NULL
INSERT INTO users (username, email, registration_date) VALUES (‘david’, NULL, ‘2023-01-04’);

— ERROR: duplicate key value violates unique constraint “users_email_key”
— INSERT INTO users (username, email, registration_date) VALUES (‘eve’, ‘[email protected]’, ‘2023-01-06’); — email unique violation
“`

bobdavidは両方ともemailがNULLですが、これは一意性違反とはなりません。もし、メールアドレスが登録されている場合は一意である必要があるが、未登録(NULL)のユーザーは何人いても構わない、というビジネス要件がある場合に、この挙動が役立ちます。

もし、NULL値も含めて一意性を強制したい(つまり、NULLを持つ行は最大で1つだけ許容したい)場合は、一意制約だけでは実現できません。この場合は、テーブル全体でNULLを持つ行が1つだけになるように制約を作成するか(少し複雑になります)、あるいはNULLを許容しないユニークなデフォルト値を設定するなどの代替手段を検討する必要があります。より一般的なアプローチとしては、一意制約を部分インデックス(後述)と組み合わせて、非NULL値のみに対して一意性を強制する方法があります。

4.4. 外部キー (Foreign Key)

外部キーは、あるテーブル(参照元テーブル)のカラムが、別のテーブル(参照先テーブル)の主キーまたは一意キーのカラムの値を参照することを保証する制約です。

外部キーカラムは、参照先テーブルの参照先カラムに存在する値、またはNULLのいずれかを持つことができます(ただし、外部キーカラム自体にNOT NULL制約が付与されている場合を除く)。

外部キーカラムにNULL値を持つ行は、その参照関係が「不明」であるか、「該当しない」ことを意味します。例えば、ordersテーブルのcustomer_idが外部キーとしてcustomersテーブルを参照している場合、customer_idがNULLの注文は、どの顧客にも紐づかない注文(ゲスト注文など)を意味する可能性があります。

外部キーが参照する行が参照先テーブルから削除または更新された場合の動作は、ON DELETE および ON UPDATE アクションで制御されます。これらアクションの中に SET NULL があります。

  • ON DELETE SET NULL: 参照先テーブルの行が削除されたとき、その行を参照していた参照元テーブルの外部キーカラムの値をNULLに設定します。
  • ON UPDATE SET NULL: 参照先テーブルの行が更新され、その主キー/一意キーの値が変わったとき、その元の値を参照していた参照元テーブルの外部キーカラムの値をNULLに設定します。

これらのアクションは、NULLをリレーションシップの切断を示すマーカーとして使用したい場合に役立ちます。

4.5. インデックス

インデックスは、データの検索速度を向上させるために使用されます。NULLを含むカラムにインデックスを作成した場合、そのインデックスがどのようにNULLを扱うかはインデックスの種類やデータベースの実装によりますが、PostgreSQLの標準的なB-treeインデックスはNULL値を格納し、利用することができます。

ただし、NULLの「値がない」という性質と3値論理のため、インデックスが効果的に使用されるクエリパターンには注意が必要です。

  • WHERE column IS NULL: この形式のクエリは、NULL値を含むインデックスを利用して、NULLの行を効率的に見つけることができます。
  • WHERE column IS NOT NULL: この形式のクエリも、インデックスを使って非NULLの行を効率的に見つけることができます。インデックスがNULL値をどのように扱うかによって、検索方法は異なりますが(NULL以外の値を全てスキャンするなど)、インデックスは利用可能です。
  • WHERE column = valueWHERE column > value など、非NULL値に対する通常の比較演算子を使ったクエリは、NULL以外の値をインデックスで検索します。

一方で、WHERE column = NULLWHERE column <> NULL は、3値論理によりUNKNOWNを返すため、通常インデックスを効果的に使用しません。

部分インデックス (Partial Indexes) とNULL

PostgreSQLの強力な機能の一つに部分インデックスがあります。これは、テーブルの行全体ではなく、特定の条件を満たす行のみに対して作成されるインデックスです。NULLを扱う上で、特にWHERE column IS NOT NULLという条件を持つ部分インデックスは非常に有用です。

例: priceがNULLでない製品に対してインデックスを作成

sql
CREATE INDEX idx_products_price_not_null ON products (price) WHERE price IS NOT NULL;

このインデックスは、priceがNULLでない行のみを対象とします。

  • SELECT * FROM products WHERE price IS NOT NULL AND price > 100.00; のようなクエリでは、このインデックスが効率的に利用される可能性があります。インデックス自体がNULLの行を含まないため、インデックススキャンがより効率的になる場合があります。
  • SELECT * FROM products WHERE price IS NULL; のようなクエリでは、このインデックスは使用されません(他のインデックスやシーケンシャルスキャンが使用されます)。

また、一意制約のセクションで触れたように、NULLを許容するカラムに対して非NULL値の一意性を強制するためにも部分インデックスが使われます。

例: descriptionがNULLでない値に対して一意性を強制

sql
CREATE UNIQUE INDEX idx_products_description_unique ON products (description) WHERE description IS NOT NULL;

これにより、descriptionに具体的な値が入力されている行の間では重複が許されませんが、descriptionがNULLの行はいくつでも存在できるようになります。

インデックス戦略を考える際は、NULLの分布と、どのようなクエリ(特にWHERE句)でそのカラムが使われるかを考慮し、必要に応じて部分インデックスや、式インデックス(例: CREATE INDEX ... ON table ((column IS NULL)) のような、NULL判定結果に対するインデックス)も検討することが推奨されます。

5. NULLに関する性能考慮事項

NULL自体の存在が直接的にクエリ性能を著しく低下させるわけではありませんが、NULLの扱い方を誤ったり、NULLに関する特定の操作を行ったりすると、性能に影響が出る可能性があります。

5.1. フィルタリング (WHERE句)

WHERE column IS NULLWHERE column IS NOT NULL によるフィルタリングは、通常効率的です。NULL値を適切にインデックス化してあれば、インデックススキャンを利用できます。

問題となるのは、前述のWHERE column = NULLのような間違いや、NULLを含む比較結果がUNKNOWNになることによる意図しない結果です。これは性能の問題というよりは正確性の問題ですが、大規模なデータセットで間違ったフィルタリングを行うと、期待しない大量の行を処理してしまう可能性があります。

5.2. 関数によるNULLの変換

COALESCE(column, default_value)NULLIF(column, specific_value) のような関数をWHERE句やJOIN条件の対象となるカラムに適用する場合、注意が必要です。

“`sql
— このクエリは idx_products_price インデックスを利用できる可能性が高い
SELECT * FROM products WHERE price > 100.00;

— このクエリは price カラム上のインデックスを直接利用できない可能性が高い
— なぜなら WHERE 句の条件が COALESCE 関数を呼び出すため
SELECT * FROM products WHERE COALESCE(price, 0) > 100.00;
“`

COALESCE(price, 0)のような関数呼び出しは、インデックスが直接格納しているカラムの値そのものではなく、その値に関数を適用した結果を評価します。PostgreSQLのオプティマイザは、このような関数呼び出しを含む条件に対して、カラム上の通常のB-treeインデックスを利用するのが難しい場合があります(不可能ではありませんが、効率が落ちることがあります)。

このような場合に性能を向上させたい場合は、式インデックス (Expression Index) を作成することを検討できます。

“`sql
— COALESCE(price, 0) の結果に対するインデックスを作成
CREATE INDEX idx_products_price_coalesced ON products (COALESCE(price, 0));

— このクエリは idx_products_price_coalesced インデックスを利用できる可能性がある
SELECT * FROM products WHERE COALESCE(price, 0) > 100.00;
``
この式インデックスは、
priceカラムの各行に対してCOALESCE(price, 0)を計算した結果を格納します。これにより、WHERE COALESCE(price, 0) > 100.00` のようなクエリが効率的に実行されるようになります。

ただし、式インデックスは通常のインデックスと同様にディスク容量を消費し、データ変更時のオーバーヘッドも発生します。頻繁に実行され、性能ボトルネックとなっているクエリに対してのみ検討すべきです。

5.3. JOIN条件におけるNULL

JOIN条件でNULLを扱う際も注意が必要です。特に内部結合 (INNER JOIN) の場合、結合条件に含まれるカラムのいずれかがNULLである行は、その条件がUNKNOWNと評価されるため、結果セットから除外されます。

例: 注文テーブルと顧客テーブルを結合

“`sql
CREATE TABLE customers (
customer_id SERIAL PRIMARY KEY,
customer_name VARCHAR(100)
);

CREATE TABLE orders (
order_id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(customer_id), — customer_id can be NULL (if no NOT NULL constraint)
order_date DATE
);

INSERT INTO customers (customer_name) VALUES (‘Alice’), (‘Bob’);
INSERT INTO orders (customer_id, order_date) VALUES (1, ‘2023-03-01’), (2, ‘2023-03-02’), (NULL, ‘2023-03-03’); — Guest order
“`

sql
-- 内部結合: customer_idがNULLの注文は結果に含まれない
SELECT o.order_id, c.customer_name
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id;

出力:
order_id | customer_name
---------+---------------
1 | Alice
2 | Bob

(Guest order (order_id=3) は含まれない)

もしNULLを持つ行も結合結果に含めたい場合は、外部結合 (LEFT JOIN, RIGHT JOIN, FULL OUTER JOIN) を使用する必要があります。

sql
-- 左外部結合: ordersテーブルの全ての行を含める
SELECT o.order_id, c.customer_name
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.customer_id;

出力:
order_id | customer_name
---------+---------------
1 | Alice
2 | Bob
3 | -- customer_id was NULL, customer_name is NULL

外部結合を使用する場合でも、結合条件にNULLが含まれると、そのペアは結合条件を満たさないことに注意が必要です(o.customer_id = c.customer_idがUNKNOWNとなる)。外部結合は、結合条件が満たされない場合に、NULLを持つ側のテーブル(LEFT JOINなら左側のテーブル)の行を保持し、対応するもう一方のテーブルのカラムにNULLを埋めて結果を返します。

もし、NULL同士を「一致」として扱って結合したいという特殊な要件がある場合は、IS NOT DISTINCT FROM を結合条件に使うか、あるいは (o.customer_id = c.customer_id) OR (o.customer_id IS NULL AND c.customer_id IS NULL) のような複雑な条件を記述する必要があります。

6. NULLを扱う上でのベストプラクティス

NULLを適切に扱うことは、データベース設計、クエリ記述、およびアプリケーション開発において非常に重要です。以下にいくつかのベストプラクティスを示します。

  1. NULLを明確に理解する: NULLがゼロや空文字列、偽とは異なる特別なマーカーであることを常に意識してください。3値論理の挙動を理解することが、意図しない結果を避ける上で不可欠です。
  2. IS NULLIS NOT NULL を使う: NULLの存在チェックには、必ずこれらの述語を使用してください。= NULL<> NULL は絶対に使用しないようにしましょう。
  3. NOT NULL 制約を適切に利用する: ビジネスロジック上、値が存在する必要があるカラムには、必ずNOT NULL制約を付与してください。これにより、データ品質が向上し、アプリケーション側でのNULLチェックの手間を減らすことができます。
  4. NULLを特定の意味として使用しない: 例えば、数値カラムでNULLを「0」として扱ったり、文字列カラムでNULLを「空文字列」として扱ったりするべきではありません。NULLはあくまで「値がない/未知/該当しない」を意味するべきです。特定の値を特別な意味で使いたい場合は、その値を明示的に格納してください。もしNULLを他の値として扱いたい場合は、クエリ内でCOALESCEなどを使って変換してください。
  5. 集計関数のNULL処理に注意する: COUNT(column_name), SUM, AVG などが集計対象からNULLを無視することを理解しておいてください。もしNULLを含めて集計したい場合は、COALESCEでNULLを適切な値に変換してから集計してください(例: AVG(COALESCE(price, 0)))。
  6. COALESCE でデフォルト値を提供する: 表示や計算のために一時的にNULLを非NULL値に置き換えたい場合は、COALESCE関数を積極的に利用してください。
  7. NULLIF で特殊な値をNULLに変換する: 特定の値が実質的にNULLと同じ意味を持つ場合に、クエリ内でそれをNULLとして扱いたい場合はNULLIFが便利です。
  8. CASE 式で複雑なNULL処理を記述する: NULLの有無や他の条件を組み合わせて複雑なロジックを実現したい場合は、CASE式が最も柔軟な方法です。
  9. JOIN条件とNULLの挙動を理解する: 特に内部結合では、結合条件に含まれるNULL値を持つ行が結果セットから除外されることを認識しておいてください。NULLを含む行も必要なら外部結合を使用しましょう。
  10. インデックス戦略にNULLを考慮に入れる: NULLが頻繁にクエリ条件として使われるカラムや、NULL値の分布に偏りがある場合は、部分インデックスや式インデックスが性能向上に役立つことがあります。
  11. ドキュメント化する: どのカラムがNULLを許容し、それぞれのNULLがどのような意味を持つのかをデータベーススキーマやアプリケーションの設計ドキュメントに明確に記述しておきましょう。

7. よくある落とし穴と対処法

NULLに関連する一般的な落とし穴と、それらを回避するための対処法をまとめます。

  • 落とし穴1: = NULL<> NULL の使用

    • 説明: 前述の通り、これは3値論理によりほとんどの場合UNKNOWNを返し、期待通りに動作しません。
    • 対処法: 必ず IS NULL または IS NOT NULL を使用してください。
  • 落とし穴2: NULLとゼロや空文字列を混同する

    • 説明: NULLは値がない状態であり、数値の0や空のテキストとは異なります。比較しても等しくなりません。
    • 対処法: NULLを他の値として扱いたい場合は、COALESCEなどで明示的に変換してください。例えば、「価格未定」をNULLで表す場合、その行のpriceカラムにはNULLを格納し、「0円」の製品とは区別してください。
  • 落とし穴3: 集計関数がNULLを無視することを考慮しない

    • 説明: SUM, AVG などはNULL値を計算に含めません。これにより、期待した合計や平均が得られないことがあります。
    • 対処法: NULLを計算に含めたい場合は、AVG(COALESCE(column, 0)) のように、NULLを適切な値に変換してから集計関数を適用してください。
  • 落とし穴4: 一意制約がNULLの重複を許容することを知らない

    • 説明: UNIQUE制約は非NULL値に対してのみ一意性を保証します。NULL値は複数存在できます。
    • 対処法: 非NULL値に対してのみ一意性を強制したい場合は、この挙動で問題ありません。もしNULLも含めて(最大1つのNULLのみ許容するなど)一意性を強制したい場合は、部分インデックス(WHERE column IS NOT NULLで一意インデックスを作成し、別途NULLが複数存在しないかチェックする)や、より複雑なチェック制約などを検討する必要があります。
  • 落とし穴5: JOIN条件でNULLを持つ行がフィルタリングされる

    • 説明: 内部結合の条件にNULLが含まれると、UNKNOWN評価によりその行が結合結果から除外されます。
    • 対処法: NULLを持つ行も結合結果に含めたい場合は、外部結合(LEFT JOINなど)を使用してください。NULL同士を一致とみなして結合したい場合は、IS NOT DISTINCT FROMを使うか、条件を明示的に記述してください。
  • 落とし穴6: 関数適用によるインデックス利用の妨げ

    • 説明: WHERE句でカラムに関数を適用すると、そのカラム上の通常のインデックスが使われにくくなることがあります。
    • 対処法: 頻繁に実行され、性能ボトルネックとなっているクエリの場合は、関数適用後の結果に対する式インデックスの作成を検討してください。

これらの落とし穴を理解し、適切なNULL判定・処理方法を選択することが、PostgreSQLを使った開発において、正確で効率的なクエリを作成するために非常に重要です。

8. まとめ

PostgreSQLにおけるNULLは、単なる「空の値」ではなく、「値がない」「未知」「該当しない」といった状態を示す特別なマーカーです。その振る舞いは、3値論理という独特のルールに基づいており、通常の比較演算子では正しく判定できません。

NULLを判定する最も基本かつ標準的な方法は IS NULL および IS NOT NULL 述語を使用することです。これらは常にTRUEまたはFALSEを返し、3値論理のUNKNOWNを生成しません。

NULLは、算術演算、文字列連結、スカラー関数など、多くの演算において結果をNULL化する性質を持ちます。しかし、集計関数 (COUNT(column_name), SUM, AVGなど) は通常、計算対象からNULLを無視します。ORDER BY句では、デフォルトで昇順では最後に、降順では最初に配置されますが、NULLS FIRST / NULLS LAST で明示的に制御できます。

NULLを扱うための便利な関数としては、COALESCE(最初の非NULL値を返す)、NULLIF(特定の値と一致したらNULLを返す)、GREATEST/LEAST(いずれかの引数がNULLならNULLを返す)などがあります。また、CASE式を使えば、NULLの有無に基づく複雑な条件分岐ロジックを記述できます。

テーブル定義における制約とNULLの関係も重要です。NOT NULL制約はNULLを禁止し、主キーカラムは常にNOT NULLです。一方、一意制約はNULLを許容し、複数のNULLが存在することを許します。外部キーはNULLを許容し得ますが、その意味とON DELETE/ON UPDATE SET NULLアクションの影響を理解する必要があります。インデックスはNULL値を格納できますが、その利用効率はクエリの書き方 (IS NULL/IS NOT NULL vs = value) やインデックスの種類(部分インデックス、式インデックス)によって変わります。

性能面では、IS NULL / IS NOT NULL は効率的ですが、WHERE句やJOIN条件でNULL関連の関数を適用すると、通常のインデックスが使いにくくなることがあります。この場合は式インデックスが有効な対策となる場合があります。

NULLの適切な扱いは、バグの少ない、意図通りの結果を返す正確なクエリを作成し、データ整合性を保ち、さらにはクエリ性能を最適化するために不可欠です。本記事で解説したNULLの基本、その振る舞い、各種関数、制約・インデックスとの関係、そしてベストプラクティスを理解し活用することで、PostgreSQLにおけるNULLとの付き合い方がよりスムーズかつ効果的になるはずです。


記事の構成:

  • はじめに:NULLとは何か?なぜ特別なのか? (NULLの定義、NULL != 0, ”, FALSE、3値論理の導入)
    1. NULLの基本判定方法:IS NULLIS NOT NULL
    2. 1.1. なぜ = NULL<> NULL を使ってはいけないのか? (3値論理とUNKNOWNの解説)
    3. 1.2. 正しいNULL判定方法:IS NULLIS NOT NULL (基本的な使い方と例)
    4. 1.3. IS DISTINCT FROMIS NOT DISTINCT FROM (NULLを値のように比較する特殊なケース)
    1. NULLがSQLの各種演算・関数に与える影響
    2. 2.1. 演算子 (算術, 文字列連結) (NULLオペランドの結果はNULL)
    3. 2.2. 関数 (多くのスカラー関数はNULLを渡されるとNULLを返す)
    4. 2.3. 集計関数 (Aggregate Functions) (COUNT, SUM, AVG, MIN, MAXのNULL無視挙動の詳細)
    5. 2.4. ORDER BY 句 (デフォルト順序、NULLS FIRST/LAST)
    1. NULLの高度な処理と変換関数
    2. 3.1. COALESCE 関数 (使い方、例、複数の代替値)
    3. 3.2. NULLIF 関数 (使い方、例、ゼロ除算回避)
    4. 3.3. GREATEST および LEAST 関数 (NULLを含むとNULLを返す挙動)
    5. 3.4. CASE 式 (IS NULL/IS NOT NULLとの組み合わせ、柔軟な処理)
    1. 制約とインデックスにおけるNULL
    2. 4.1. NOT NULL 制約 (必須項目)
    3. 4.2. 主キー (NULL不可)
    4. 4.3. 一意制約 (NULL許容、NULL重複可)
    5. 4.4. 外部キー (NULL許容、ON DELETE/UPDATE SET NULL)
    6. 4.5. インデックス (B-treeのNULL扱い、部分インデックス、式インデックス)
    1. NULLに関する性能考慮事項
    2. 5.1. フィルタリング (IS NULL/IS NOT NULLは効率的)
    3. 5.2. 関数によるNULLの変換 (関数適用とインデックス、式インデックスの活用)
    4. 5.3. JOIN条件におけるNULL (内部結合でのフィルタリング、外部結合、IS NOT DISTINCT FROM)
    1. NULLを扱う上でのベストプラクティス (要点のまとめ)
    1. よくある落とし穴と対処法 (具体的な間違いとその修正方法)
    1. まとめ (記事全体の要約、NULL理解の重要性)

上記の構成に基づき、詳細な説明とコード例を各セクションに盛り込みました。語数は約5000語になるように、各トピックを掘り下げて解説しています。


コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール