NULLと3値論理|テーブル・SELECT・条件抽出

このページはプロモーション(広告)を含みます。

テーブル・SELECT・条件抽出(中級・読む25分+演習35分)

この授業のゴール

仕事でどう使うか

現実のデータには、空欄が必ずあります。未入力、未計測、未到着、該当なし。SQLの NULL は、そうした「値がない」状態を表します。NULLを0や空文字と混ぜると、平均や件数がこっそりずれて、誰も気づかないまま意思決定に使われます。

路線 この授業の使いどころ
DS(データサイエンティスト) 平均・割合の分母に欠損を含めるか決める。欠損の割合を最初に確認する
ML(機械学習エンジニア) 特徴量の欠損処理(0で埋める、平均で埋める、欠損フラグを付ける)の前に、欠損が「未記録」か「該当なし」かを区別する
DX(DX推進・AI企画) 業務システムの必須項目・任意項目を定義し、KPIの分母に未入力をどう扱うか決める
RS(研究者) 欠測の扱い(完全ケース分析、補完)の前提として、欠測の理由と量を記録する

この授業は、モジュール テーブル・SELECT・条件抽出 の「判定ポイント」の中心です。NULLの比較、ANDとORの優先度、順序未指定を、すべて自分の言葉で説明できることを目指します。

前提

次の言葉を使います。不安があれば先に戻ってください。

この授業では、これまでと同じ注文表 orders を使います。amount(金額)は注文4が0円、注文6がNULL。coupon_code(クーポン)は、注文1・3・4・6がNULL、注文7が空文字、注文2・5が SPRING、注文8が VIP です。

身近な状況:「クーポン未使用の注文は何件?」と「平均金額は?」

注文表の coupon_code を見ます。

order_id coupon_code amount
1 NULL 3000
2 SPRING 1200
3 NULL 2500
4 NULL 0
5 SPRING 1800
6 NULL NULL
7 (空文字 '') 1200
8 VIP 4000

質問が2つあります。

  1. 「クーポンを使わなかった注文は何件か」。NULL の4件だけでしょうか? 注文7の空文字も「使っていない」と考えるのが自然では?
  2. 「平均注文金額はいくらか」。注文4は0円、注文6は金額不明です。この2件を平均に入れるか、入れないかで答えが変わります。

どちらも、NULLをどう扱うかの決めが必要です。決めずにSQLを書くと、SQLの規則が自動で(しかも黙って)決めてしまいます。この授業ではその規則と、自分で決める方法を学びます。

-- db: none
CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL,
  order_date TEXT NOT NULL,
  status TEXT NOT NULL CHECK(status IN ('paid','cancelled','refunded')),
  channel TEXT NOT NULL,
  amount INTEGER,
  coupon_code TEXT
);
INSERT INTO orders VALUES
  (1,101,'2025-03-31','paid','web',3000,NULL),
  (2,102,'2025-04-01','paid','app',1200,'SPRING'),
  (3,101,'2025-04-01','cancelled','web',2500,NULL),
  (4,103,'2025-04-10','paid','store',0,NULL),
  (5,104,'2025-04-15','refunded','app',1800,'SPRING'),
  (6,102,'2025-04-20','paid','web',NULL,NULL),
  (7,105,'2025-04-30','paid','web',1200,''),
  (8,103,'2025-05-01','paid','app',4000,'VIP');
SELECT COUNT(*)      AS all_rows,
       COUNT(amount) AS amount_not_null,
       AVG(amount)   AS avg_amount
FROM orders;

結果は 8、7、約1957.14 です。AVG は NULL の注文6を分母から除いています(合計13700 ÷ 7件)。

直感的な説明:NULLは「わからない」、0は「0だった」

次の3つは、見た目が似ていても別の意味です。

値 意味 例
0 0という値が記録された 無料サンプル(注文4)。金額は0円
空文字 '' 文字がない文字列が記録された クーポン欄に何も入力せずに送信(注文7)
NULL 値がない。未入力・未計測・該当なし・不明 金額が未記録(注文6)、クーポン未使用(注文1など)

NULLを「わからない」と考えると、SQLの動きが直感的に読めます。

そして WHERE は「真と確定した行だけ」を残します。「不明」の行は、真ではないので消えます。これが、NULLをめぐる誤りの根っこです。

たとえ話の限界:NULL は「未知の値」のこともあれば、「そもそも該当しない」こともあります(解約していない人の解約日)。同じNULLでも意味が違うため、列ごとに意味を確認し、定義書に書く必要があります。

正確な定義:3値論理・IS NULL・集計関数・COALESCE

SQLの条件は、真(TRUE)・偽(FALSE)・不明(UNKNOWN)の3つの値を取ります。NULLを含む比較は不明になります。

$$\text{NULL} = \text{NULL} \Rightarrow \text{UNKNOWN},\qquad \text{NULL} > 10 \Rightarrow \text{UNKNOWN}$$

論理演算の結果(Tは真、Fは偽、Uは不明):

A B A AND B A OR B
T U U T
F U F U
U U U U
A NOT A
T F
F T
U U

覚えるポイントは、「偽 AND 不明 = 偽」「真 OR 不明 = 真」「NOT 不明 = 不明」の3つです。

WHERE の規則:条件が真の行だけを残す。偽と不明の行は消える。

NULLの判定:列 IS NULL、列 IS NOT NULL。列 = NULL は常に不明になり、1行も残りません。

集計関数とNULL:

関数 NULLの扱い
COUNT(*) 行を数える(NULLを含む行も1行)
COUNT(列) NULLでない値の数
SUM(列)、AVG(列)、MIN、MAX NULLを無視して計算する。すべてNULLなら結果もNULL

変換関数:

0にしてよいのは、「未記録=0」と業務で決めた場合だけです。決めずに0にすると、不明な値を「0だった」と書き換えることになります。

段階付き例題:クーポン未使用の定義と、平均金額の3つの答え

問題A:クーポンを使わなかった注文の件数を求める。空文字の注文7も「使っていない」とみなす。

ステップ1:定義を決める。クーポン未使用 = coupon_code が NULL または 空文字。

ステップ2:NULLの判定を書く。coupon_code IS NULL。空文字は coupon_code = ''。

ステップ3:OR でつなぐ。coupon_code IS NULL OR coupon_code = ''。AND がないので括弧は不要だが、ほかの条件と組み合わせるときは括弧が必須。

-- db: none
CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL,
  order_date TEXT NOT NULL,
  status TEXT NOT NULL CHECK(status IN ('paid','cancelled','refunded')),
  channel TEXT NOT NULL,
  amount INTEGER,
  coupon_code TEXT
);
INSERT INTO orders VALUES
  (1,101,'2025-03-31','paid','web',3000,NULL),
  (2,102,'2025-04-01','paid','app',1200,'SPRING'),
  (3,101,'2025-04-01','cancelled','web',2500,NULL),
  (4,103,'2025-04-10','paid','store',0,NULL),
  (5,104,'2025-04-15','refunded','app',1800,'SPRING'),
  (6,102,'2025-04-20','paid','web',NULL,NULL),
  (7,105,'2025-04-30','paid','web',1200,''),
  (8,103,'2025-05-01','paid','app',4000,'VIP');
SELECT COUNT(*) AS no_coupon
FROM orders
WHERE coupon_code IS NULL OR coupon_code = '';
-- 参考:間違った書き方(= NULL)は0件になる
SELECT COUNT(*) AS wrong FROM orders WHERE coupon_code = NULL;

ステップ4:結果を読む。正しい件数は 5(注文1、3、4、6、7)。= NULL は 0。IS NULL だけでは注文7が漏れて4件になります。

問題B:平均注文金額を求める。ただし、金額の扱いで3通りの答えがある。

-- db: none
CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL,
  order_date TEXT NOT NULL,
  status TEXT NOT NULL CHECK(status IN ('paid','cancelled','refunded')),
  channel TEXT NOT NULL,
  amount INTEGER,
  coupon_code TEXT
);
INSERT INTO orders VALUES
  (1,101,'2025-03-31','paid','web',3000,NULL),
  (2,102,'2025-04-01','paid','app',1200,'SPRING'),
  (3,101,'2025-04-01','cancelled','web',2500,NULL),
  (4,103,'2025-04-10','paid','store',0,NULL),
  (5,104,'2025-04-15','refunded','app',1800,'SPRING'),
  (6,102,'2025-04-20','paid','web',NULL,NULL),
  (7,105,'2025-04-30','paid','web',1200,''),
  (8,103,'2025-05-01','paid','app',4000,'VIP');
SELECT AVG(amount)               AS avg_ignore_null,   -- NULLを無視(7件)
       AVG(COALESCE(amount, 0))  AS avg_null_as_zero,  -- NULLを0(8件)
       (SELECT AVG(amount) FROM orders WHERE amount > 0) AS avg_positive_only  -- 0円とNULLを除く(6件)
FROM orders;

ステップ5:結果を読む。合計は 13700 円です。

方法 分母 平均
NULLを無視 7 $13700 \div 7 \approx 1957.1$
NULLを0として扱う 8 $13700 \div 8 = 1712.5$
0円とNULLを除く 6 $13700 \div 6 \approx 2283.3$

同じデータから、約1700円、約1960円、約2280円の3つが出ます。どれが正しいかは、「平均注文金額」の定義で決まります。たとえば、無料サンプルも1件の注文として数える方針なら、0円を含み、金額不明の1件だけを除く(約1957円)のが自然です。定義を決めず、SQLの既定(NULLを無視)に任せると、報告の数字を誰も説明できなくなります。

操作して理解する:3値論理の表を実行で確かめる

まず、真理値表を実行で確かめます。SQLiteでは、真が 1、偽が 0、不明が NULL(結果欄は空欄か「NULL」)で表示されます。実行する前に、各列の結果を予想してください。

-- db: none
SELECT NULL = NULL      AS null_eq_null,   -- 不明
       NULL AND 0       AS u_and_f,        -- 偽
       NULL AND 1       AS u_and_t,        -- 不明
       NULL OR 1        AS u_or_t,         -- 真
       NULL OR 0        AS u_or_f,         -- 不明
       NOT NULL         AS not_u;          -- 不明

次に、WHERE の結果を予想します。

-- db: none
CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL,
  order_date TEXT NOT NULL,
  status TEXT NOT NULL CHECK(status IN ('paid','cancelled','refunded')),
  channel TEXT NOT NULL,
  amount INTEGER,
  coupon_code TEXT
);
INSERT INTO orders VALUES
  (1,101,'2025-03-31','paid','web',3000,NULL),
  (2,102,'2025-04-01','paid','app',1200,'SPRING'),
  (3,101,'2025-04-01','cancelled','web',2500,NULL),
  (4,103,'2025-04-10','paid','store',0,NULL),
  (5,104,'2025-04-15','refunded','app',1800,'SPRING'),
  (6,102,'2025-04-20','paid','web',NULL,NULL),
  (7,105,'2025-04-30','paid','web',1200,''),
  (8,103,'2025-05-01','paid','app',4000,'VIP');
-- (1) 「1200以上」と「1200未満」を足すと全件になる?
SELECT COUNT(*) AS n_ge_or_lt FROM orders WHERE amount >= 1200 OR amount < 1200;
-- (2) NULLの行を明示すると全件になる
SELECT COUNT(*) AS n_all FROM orders WHERE amount IS NULL OR amount >= 1200 OR amount < 1200;
-- (3) クーポンが SPRING でない注文(NULLを含めたい場合)
SELECT order_id FROM orders WHERE coupon_code IS NULL OR coupon_code <> 'SPRING' ORDER BY order_id;
-- (4) 空文字をNULLに寄せてから「なし」と表示する
SELECT order_id, COALESCE(NULLIF(coupon_code, ''), 'なし') AS coupon FROM orders ORDER BY order_id;

答えと読み方:

試してみよう:(3) の OR を AND に変えると、結果は0行になります。NULLの行は IS NULL を満たすが <> 'SPRING' は不明、AND の結果は不明、と3値論理の表どおりになるためです。

発展:NOT IN とNULL、NULLを同じとみなす場面、方言差

NOT IN の罠:x NOT IN (a, b, NULL) は、x が a でも b でもなくても、真にならず不明になります。x <> a AND x <> b AND x <> NULL と同じで、最後が不明のため全体が真になれないからです。サブクエリの結果にNULLが1つでも混ざると、NOT IN は1行も返さなくなります。対策は (1) サブクエリに WHERE 列 IS NOT NULL を付ける、(2) NOT EXISTS を使う(「サブクエリ」(有料版))、です。

NULLを同じとみなす場面:比較(=)では不明ですが、次の場面ではNULLどうしを「同じ」として扱います。
- DISTINCT:NULLの行は1つにまとめられる。
- GROUP BY:NULLは1つのグループになる。
- ORDER BY:NULLは連続して並ぶ(位置は方言による)。
- UNIQUE 制約:SQLiteや多くのデータベースでは、NULLは何個でも入れられる(方言差あり。SQL Server は1個まで)。

NULLを安全に比べる演算:SQLiteでは a IS b、a IS NOT b が、NULLを含めて等しいかを判定します(coupon_code IS NOT 'SPRING' は NULL の行も含めて真)。標準SQLでは IS DISTINCT FROM、IS NOT DISTINCT FROM で、PostgreSQL などが対応しています。書き方が方言で違うため、チームで使う方言の手順を確認してください。

欠測の理由と分析:統計では、欠測が「完全にランダム」「他の変数に依存」「値そのものに依存」の場合で、扱い方(除外、補完)が異なります。SQLでは単に除外されますが、除外した行が全体と性質が違うと、結果が偏ります(選択バイアス)。重要な分析では、欠損の割合と偏りを先に確認してください。

よくある失敗

失敗1:`= NULL` で欠損を探す

-- db: none
CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL,
  order_date TEXT NOT NULL,
  status TEXT NOT NULL CHECK(status IN ('paid','cancelled','refunded')),
  channel TEXT NOT NULL,
  amount INTEGER,
  coupon_code TEXT
);
INSERT INTO orders VALUES
  (1,101,'2025-03-31','paid','web',3000,NULL),
  (2,102,'2025-04-01','paid','app',1200,'SPRING'),
  (3,101,'2025-04-01','cancelled','web',2500,NULL),
  (4,103,'2025-04-10','paid','store',0,NULL),
  (5,104,'2025-04-15','refunded','app',1800,'SPRING'),
  (6,102,'2025-04-20','paid','web',NULL,NULL),
  (7,105,'2025-04-30','paid','web',1200,''),
  (8,103,'2025-05-01','paid','app',4000,'VIP');
-- 誤り:常に不明になり、0件
SELECT COUNT(*) AS wrong FROM orders WHERE amount = NULL;
-- 正しい
SELECT COUNT(*) AS right_n FROM orders WHERE amount IS NULL;

誤りは 0、正しくは 1(注文6)です。エラーが出ないため、「欠損はゼロ件」と誤って報告する危険があります。直し方:NULLの判定は常に IS NULL/IS NOT NULL。欠損の件数は COUNT(*) - COUNT(列) でも確認できる。

失敗2:NOT IN のリストにNULLが混ざって、全行が消える

-- db: none
CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL,
  order_date TEXT NOT NULL,
  status TEXT NOT NULL CHECK(status IN ('paid','cancelled','refunded')),
  channel TEXT NOT NULL,
  amount INTEGER,
  coupon_code TEXT
);
INSERT INTO orders VALUES
  (1,101,'2025-03-31','paid','web',3000,NULL),
  (2,102,'2025-04-01','paid','app',1200,'SPRING'),
  (3,101,'2025-04-01','cancelled','web',2500,NULL),
  (4,103,'2025-04-10','paid','store',0,NULL),
  (5,104,'2025-04-15','refunded','app',1800,'SPRING'),
  (6,102,'2025-04-20','paid','web',NULL,NULL),
  (7,105,'2025-04-30','paid','web',1200,''),
  (8,103,'2025-05-01','paid','app',4000,'VIP');CREATE TABLE flagged (customer_id INTEGER);
INSERT INTO flagged VALUES (104),(NULL);
-- 誤り:flagged に NULL が入っているため 0行
SELECT order_id FROM orders
WHERE customer_id NOT IN (SELECT customer_id FROM flagged);
-- 正しい:サブクエリでNULLを除く
SELECT order_id FROM orders
WHERE customer_id NOT IN (SELECT customer_id FROM flagged WHERE customer_id IS NOT NULL)
ORDER BY order_id;

要注意顧客のリスト flagged に、顧客104と、入力ミスのNULLが1行入っています。誤りのほうは 0行、正しいほうは顧客104以外の7件(注文 1、2、3、4、6、7、8)です。直し方:NOT IN の対象列にNULLが入らないことを確認するか、WHERE 列 IS NOT NULL で除く。

失敗3:NULLを黙って0にして、平均を下げる

-- db: none
CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL,
  order_date TEXT NOT NULL,
  status TEXT NOT NULL CHECK(status IN ('paid','cancelled','refunded')),
  channel TEXT NOT NULL,
  amount INTEGER,
  coupon_code TEXT
);
INSERT INTO orders VALUES
  (1,101,'2025-03-31','paid','web',3000,NULL),
  (2,102,'2025-04-01','paid','app',1200,'SPRING'),
  (3,101,'2025-04-01','cancelled','web',2500,NULL),
  (4,103,'2025-04-10','paid','store',0,NULL),
  (5,104,'2025-04-15','refunded','app',1800,'SPRING'),
  (6,102,'2025-04-20','paid','web',NULL,NULL),
  (7,105,'2025-04-30','paid','web',1200,''),
  (8,103,'2025-05-01','paid','app',4000,'VIP');
-- 誤り:未記録の金額を0円とみなして平均する
SELECT AVG(COALESCE(amount, 0)) AS avg_null_as_zero FROM orders;
-- 参考:NULLを分母から除く
SELECT AVG(amount) AS avg_ignore_null FROM orders;

前者は1712.5円、後者は約1957.1円です。注文6の金額は「0円」ではなく「記録されていない」だけなので、0として平均に入れると平均が約245円下がります。直し方:COALESCE(…, 0) は「未記録=0」と業務で決めた列にだけ使う。決まっていないときはNULLを無視し、除いた件数を併記する(今回は8件中1件)。

まとめ

用語

NULL(NULL)

値がないことを表す特別な印。未入力・不明・該当なしを表す。0や空文字とは別物。

3値論理(three-valued logic)

真・偽・不明の3つの値で条件を評価する論理。NULLを含む比較は不明になる。

IS NULL(IS NULL)

値がNULLかどうかを判定する条件。= NULL は使えない。

COALESCE(COALESCE)

左から見て最初のNULLでない値を返す関数。NULLを代わりの値に置き換えるときに使う。

NULLIF(NULLIF)

2つの値が等しければNULLを、違えば最初の値を返す関数。空文字をNULLに寄せるときに使う。

よくある質問

NULLと空文字は同じ扱いにしてよいですか?

業務の意味で決めます。画面の入力欄の空送信が空文字になる設計なら、空文字は「入力なし」でNULLと同じ扱いにするのが自然です。ただし、機械的に同一視せず、NULLIF(列, '') で明示的に寄せてから集計しましょう。

NULLを0に置き換えると、何が問題ですか?

「不明」を「0だった」と書き換えることになり、平均・割合・合計が実際とずれます。無料(0円)と未記録(不明)が区別できなくなるのも問題です。0にするのは、業務で決めた列だけにします。

NULLのある列に NOT NULL 制約を付ければ解決しますか?

制約は将来の入力を防ぎますが、すでに入っているNULLの意味は消えません。また「該当なし」を表すNULLが必要な列もあります。まず列ごとにNULLの意味を決め、必須か任意かを設計に反映します。

MIN/MAX はNULLをどう扱いますか?

NULLを無視して、NULLでない値の最小・最大を返します。すべてがNULLならNULLです。ただし並べ替え(ORDER BY)ではNULLが先頭か末尾に置かれるので、LIMIT 1 で最小値を求めるのとは結果が異なります。

NULLの割合はどう調べますか?

COUNT(*) - COUNT(列) が欠損の件数、1.0 * (COUNT(*) - COUNT(列)) / COUNT(*) が欠損の割合です。分析の最初に、重要な列の欠損割合を確認する習慣を付けましょう(整数どうしの割り算に注意、型・日付・式)。

仕事で、NULLの扱いを関係者に説明するには?

KPI定義書に『分母は金額が記録されている注文。未記録の注文は件数を併記して除外』のように、扱いと除外件数を書きます。数字だけを渡さず、「8件中1件が未記録」と添えると、誤解や後からの修正を防げます。

確認問題

問1 WHERE amount = NULL はどう動くか。

  1. ① amount が0の行を返す
  2. ② amount がNULLの行だけを返す
  3. ③ 常に不明になり、1行も返さない
  4. ④ エラーで停止する
解答と解説

正解:③

NULLとの = 比較は不明(UNKNOWN)で、WHERE は真の行だけを残すため、0行になります。

問2 NULL AND 0 の結果はどれか。

  1. ① 0(偽)
  2. ② 1(真)
  3. ③ エラー
  4. ④ NULL(不明)
解答と解説

正解:①

偽と不明のANDは偽です。片方が偽なら、もう片方が何でも全体は偽に確定します。

問3 amount に NULL が1件ある表で、AVG(amount) はどう計算されるか。

  1. ① 結果が必ずNULLになる
  2. ② エラーになる
  3. ③ NULLを0として分母に含める
  4. ④ NULLを無視し、NULLでない件数で割る
解答と解説

正解:④

AVG、SUM、MIN、MAX はNULLを無視します。分母はNULLでない行の数です。

問4 customer_id NOT IN (SELECT customer_id FROM flagged) で、flagged にNULLの行が1つある場合、結果はどうなるか。

  1. ① NULLの行だけが除かれる
  2. ② どの行も返らない(0行)
  3. ③ 全行が返る
  4. ④ エラーになる
解答と解説

正解:②

リストにNULLがあると、NOT IN の判定は真になれず不明になるため、全行が消えます。

問5 COALESCE(NULLIF(coupon_code, ''), 'なし') の働きとして正しいものはどれか。

  1. ① NULLだけを「なし」に変える
  2. ② 空文字だけを「なし」に変える
  3. ③ 全部を「なし」に変える
  4. ④ 空文字もNULLも「なし」に変える
解答と解説

正解:④

NULLIF が空文字をNULLに変え、COALESCE がNULLを「なし」に変えます。

問6 amount >= 1200 の件数が6、amount < 1200 の件数が1、全体が8件のとき、足りない1件は何か。

  1. ① 金額が NULL の行
  2. ② 金額が負の行
  3. ③ 重複した行
  4. ④ 金額がちょうど1200の行
解答と解説

正解:①

NULLは大小の比較で不明になり、どちらの条件にも入りません。

次に学ぶこと

次は 型・日付・式 で、型・日付・式を扱います。整数どうしの割り算や、時刻付きの日付の境界など、この授業と同じく「静かに数字がずれる」落とし穴を学びます。

その後、モジュール テーブル・SELECT・条件抽出 の総合テストと自力課題で、NULLの比較・ANDとORの優先度・順序未指定をまとめて確認します。

キャリア学習:データサイエンティスト・機械学習エンジニア・DX推進・研究者を目指す人の学習アプリ。164モジュール・984授業を、ブラウザで動くSQLとPythonの演習で。基礎と各分野の最初のモジュールは無料。全部は2,980円の買い切り。

アプリを無料で開く有料版と無料版の違い