IS NULL / IS NOT NULL — NULL との等値比較は「常に UNKNOWN」になる
SQL には TRUE / FALSE / UNKNOWN の3値論理があります。NULL との全ての比較演算(=、!=、<、>)は UNKNOWN を返す ため、WHERE deleted_at = NULL は期待どおりに動きません。
-- ✗ 動かない(全行除外される) WHERE deleted_at = NULL -- NULL = NULL → UNKNOWN → WHERE が除外 -- ✓ 正しい WHERE deleted_at IS NULL -- NULL IS NULL → TRUE → 通過 WHERE deleted_at IS NOT NULL -- NULL IS NOT NULL → FALSE → 除外
IS NULL と IS NOT NULL だけが NULL に対して確実に TRUE または FALSE を返せる唯一の演算子です。users テーブルから、deleted_at が NULL のユーザーは 'active'、NULL でないユーザーは 'deleted' の status を付与してください。出力列は id, name, status、id 昇順で返してください。
| id | name | deleted_at |
|---|---|---|
| 1 | 田中 | NULL |
| 2 | 鈴木 | 2024-01-10 |
| 3 | 佐藤 | NULL |
| 4 | 伊藤 | 2024-01-15 |
| 5 | 山田 | NULL |
| id | name | status |
|---|---|---|
| 1 | 田中 | active |
| 2 | 鈴木 | deleted |
| 3 | 佐藤 | active |
| 4 | 伊藤 | deleted |
| 5 | 山田 | active |
SELECT id, name, CASE WHEN deleted_at IS NULL THEN 'active' -- NULL → アクティブ ELSE 'deleted' -- 非 NULL → 退会済み END AS status FROM users ORDER BY id; /* 実行順序(SQLの論理的な評価順): 1. FROM users → 行を読込 2. CASE WHEN deleted_at IS NULL → IS NULL で正しく判定 3. SELECT id, name, status → 3列を出力 4. ORDER BY id → id 昇順 */
LEGEND
① FROM users(5行)
FROM usersusers テーブルの5行を読み込みます。deleted_at 列に NULL(退会日未設定 = アクティブ)と日付値(退会日設定 = 退会済み)が混在しています。| id | name | deleted_at |
|---|---|---|
| 1 | 田中 | NULL |
| 2 | 鈴木 | 2024-01-10 |
| 3 | 佐藤 | NULL |
| 4 | 伊藤 | 2024-01-15 |
| 5 | 山田 | NULL |
NULL = NULL は直感に反して TRUE にならない点が最大の落とし穴です。IS NULL は対象が NULL のとき TRUE を返し、IS NOT NULL は非 NULL のとき TRUE を返します。この2つの演算子だけが NULL に対して確実に TRUE または FALSE を返せます。NULL チェックには必ず IS NULL / IS NOT NULL を使うのが鉄則です。deleted_at IS NULL は「退会日が未設定」= アクティブユーザー を意味します。ソフトデリートパターンでは deleted_at が NULL かどうかで論理削除を管理し、WHERE deleted_at IS NULL を付け忘れると退会済みユーザーが分析に混入するバグになります。WHERE col = NULL / WHERE col != NULL で全行除外:等値比較・不等値比較どちらも NULL との組み合わせでは UNKNOWN を返すため、WHERE 句を通過する行が0件になります。「NULL でない行を探す」目的で col != NULL と書くのも同じ罠です。必ず IS NULL / IS NOT NULL を使ってください。WHERE score > 0 AND score != NULL のように書くと score != NULL が UNKNOWN になり、AND の結果も UNKNOWN → 全行除外になります。NULL チェックは IS NULL を単独で行い、それを AND/OR で他の条件と組み合わせる形にしてください。また NOT (col IS NULL) は col IS NOT NULL と同義です。DELETE を実行する代わりに deleted_at が NULL かどうかで「削除済み」を管理します。クエリに WHERE deleted_at IS NULL を書き忘れると退会済みユーザーがレポートに混入します。dbt では Macro やビューで WHERE deleted_at IS NULL を一箇所に集約し、下流クエリが NULL チェックを意識しなくてよい設計にすることがベストプラクティスです。同様に is_active フラグ(BOOLEAN)を別途設けてアプリ側で管理し、クエリ側のミスを防ぐ設計も有効です。COUNT(*) vs COUNT(col) — NULL は集計から「静かに消える」
全ての標準 SQL 集約関数(COUNT・SUM・AVG・MIN・MAX)は NULL を無視します。唯一の例外が COUNT(*) で、NULL の有無に関係なく全行を数えます。
COUNT(*) -- 全行を数える(NULL を含む) COUNT(coupon_code) -- NULL を除いて数える(非 NULL 件数のみ) SUM(amount) -- NULL 行を除いて合計 AVG(score) -- NULL 行を除いて平均(分母も NULL 行を引く)
COUNT(col) は式の値が NULL のとき、その行をカウントから除外します。COUNT(*) は行そのものを数えるため NULL の影響を受けません。この差が利用率・回答率・完了率など「全体に占める割合」の計算で重大な意味を持ちます。orders テーブルから、全注文数(total_orders)・クーポン利用注文数(coupon_orders)・クーポン利用率(%)(coupon_rate)を1行で返してください。coupon_rate は小数第1位まで丸めてください。
| order_id | user_id | amount | coupon_code |
|---|---|---|---|
| 1 | 101 | 3000 | SALE10 |
| 2 | 102 | 5000 | NULL |
| 3 | 101 | 2000 | SALE10 |
| 4 | 103 | 8000 | NULL |
| 5 | 104 | 1500 | NEW20 |
| 6 | 102 | 4000 | NULL |
| total_orders | coupon_orders | coupon_rate |
|---|---|---|
| 6 | 3 | 50.0 |
SELECT COUNT(*) AS total_orders, -- 全6行(NULL 含む) COUNT(coupon_code) AS coupon_orders, -- NULL 除外 → 3行 ROUND( COUNT(coupon_code) * 100.0 -- 100.0 で numeric 変換(整数除算を回避) / COUNT(*), 1 ) AS coupon_rate FROM orders; /* 実行順序(SQLの論理的な評価順): 1. FROM orders → 行を読込 2. COUNT(*) → 全行をカウント 3. COUNT(coupon_code) → NULL 行を除外してカウント 4. COUNT(coupon_code) * 100.0 / COUNT(*) → NULL率を計算 5. ROUND(..., 1) → 小数第1位で丸め 6. SELECT → 3列を1行で出力 */
LEGEND
① FROM orders(6行)
FROM ordersorders テーブルの6行を読み込みます。coupon_code 列に NULL(クーポン未使用:order_id=2,4,6)と値(クーポン使用:order_id=1,3,5)が混在しています。| order_id | user_id | amount | coupon_code |
|---|---|---|---|
| 1 | 101 | 3000 | SALE10 |
| 2 | 102 | 5000 | NULL |
| 3 | 101 | 2000 | SALE10 |
| 4 | 103 | 8000 | NULL |
| 5 | 104 | 1500 | NEW20 |
| 6 | 102 | 4000 | NULL |
COUNT(expr) は式の値が NULL のとき、その行をカウントから除外します。COUNT(*) は行そのものを数えるため NULL の影響を受けません。この差が利用率・回答率・完了率などを正確に計算するための鍵になります。COUNT(*) - COUNT(coupon_code) = 3 がクーポン未使用注文数です。これは COUNT(CASE WHEN coupon_code IS NULL THEN 1 END) と等価ですが、より簡潔に書けます。COUNT(coupon_code) * 100.0 / COUNT(coupon_code) とすると常に 100% になります。クーポン利用率は「全注文に対する割合」なので、分母は必ず COUNT(全体)= COUNT(*) にしてください。何が母数(分母)になるべきかを明確にすることが集計設計の基本です。COUNT(coupon_code) / COUNT(*) = 3 / 6 = 0(PostgreSQL の整数除算)。必ず * 100.0 や ::numeric でキャストしてから除算してください。リテラル 100.0 を掛けることで自動的に numeric 型に昇格し、小数の計算が可能になります。COUNT(*) を使うか COUNT(col) を使うかで集計値が大きく変わります。NULL が「未実施」を意味するのか「データなし(欠損)」を意味するのかをデータ定義書で明確化し、集計の分母を統一することがデータ品質管理の基本です。特にダッシュボードの KPI 定義では「誰でも同じ計算で同じ数値を得られるか」を基準に SQL を設計してください。COALESCE / NULLIF — NULL を安全に「置換」し「生成」する2つの武器
COALESCE と NULLIF は NULL を安全に扱うための関数です。どちらも実務で頻出する NULL 対策の定番パターンです。
-- COALESCE: 左から順に評価し、最初の非 NULL 値を返す COALESCE(discount_price, price) -- discount_price = NULL → price を使う(フォールバック) -- discount_price = 800 → 800 を返す(price は無視) -- NULLIF: a = b のとき NULL を返す(それ以外は a を返す) NULLIF(stock, 0) -- stock = 0 → NULL(ゼロ除算エラーを回避) -- stock = 50 → 50(そのまま返す)
x / NULLIF(y, 0) は y = 0 のとき NULL を返すため、Division by zero エラーを回避できます。NULLIF は「特定の値を NULL に変換して計算を安全にする」逆転の発想の関数です。products テーブルから、実売価格(discount_price があれば discount_price、なければ price → effective_price)と在庫回転率(sold ÷ stock → turnover_rate、stock が 0 のとき NULL)を計算してください。出力列は product_id, name, effective_price, turnover_rate、product_id 昇順で返してください。turnover_rate は小数第2位まで丸めてください。
| product_id | name | price | discount_price | stock | sold |
|---|---|---|---|---|---|
| 1 | A | 1000 | 800 | 50 | 20 |
| 2 | B | 2000 | NULL | 30 | 30 |
| 3 | C | 500 | 400 | 0 | 0 |
| 4 | D | 3000 | 2500 | 100 | 40 |
| 5 | E | 1500 | NULL | 0 | 5 |
| product_id | name | effective_price | turnover_rate |
|---|---|---|---|
| 1 | A | 800 | 0.40 |
| 2 | B | 2000 | 1.00 |
| 3 | C | 400 | NULL |
| 4 | D | 2500 | 0.40 |
| 5 | E | 1500 | NULL |
SELECT product_id, name, COALESCE(discount_price, price) AS effective_price, -- 最初の非 NULL を返す ROUND( sold::numeric / NULLIF(stock, 0), -- stock=0 → NULL(ゼロ除算回避) 2 ) AS turnover_rate FROM products ORDER BY product_id; /* 実行順序(SQLの論理的な評価順): 1. FROM products → 行を読込 2. COALESCE(discount_price, price) → 最初の非 NULL 値を返す 3. NULLIF(stock, 0) → stock=0 を NULL に変換 4. sold::numeric / NULLIF(stock, 0) → 回転率を計算(0除算回避) 5. ROUND(..., 2) / SELECT / ORDER BY → 丸めて出力 */
LEGEND
① FROM products(5行)
FROM productsproducts テーブルの5行を読み込みます。discount_price 列に NULL(セールなし)と値(セール価格あり)が混在。stock=0 の行(id=3,5)がゼロ除算の罠になります。| product_id | name | price | discount_price | stock | sold |
|---|---|---|---|---|---|
| 1 | A | 1000 | 800 | 50 | 20 |
| 2 | B | 2000 | NULL | 30 | 30 |
| 3 | C | 500 | 400 | 0 | 0 |
| 4 | D | 3000 | 2500 | 100 | 40 |
| 5 | E | 1500 | NULL | 0 | 5 |
COALESCE(a, b, c) のように 引数を複数チェーンして多段フォールバックを構成できます。全引数が NULL の場合のみ NULL を返します。NULL 許容列のデフォルト値設定に最も広く使われる関数です。NULLIF(a, b) は a = b のとき NULL を返し、それ以外は a をそのまま返します。ゼロ除算防止の x / NULLIF(y, 0) パターンだけでなく、空文字を NULL に統一する NULLIF(col, '') や、番兵値(-1 = 未設定)を NULL に変換する用途にも使えます。COALESCE(price, '価格なし') のように INT と TEXT を混在させるとキャストエラーになります。数値列のデフォルトは 0(同じ数値型)、文字列列のデフォルトは ''(空文字)にするか、明示的に ::text でキャストして型を統一してください。IFNULL(a, b) や ISNULL(a, b) は PostgreSQL では動作しません(関数が存在しない)。標準 SQL の COALESCE を使うことで全ての DBMS に移植できます。チームのコードベースを統一するためにも COALESCE を使う習慣をつけてください。COUNT(COALESCE(score, 0)) はすべての行の score が非 NULL になるため、COUNT(score) と異なる結果(= COUNT(*))になります。「NULL も 0 として集計に含める」意図がある場合のみ COALESCE を使い、「NULL 行を除いたカウント」なら COUNT(score) をそのまま使ってください。AVG と NULL — 「平均が静かに嘘をつく」メカニズムを理解する
集約関数は NULL を自動的に除外して計算するため、結果の分母が変わります。これが「平均が静かに嘘をつく」バグの源です。
-- score = [80, NULL, 90, NULL] の場合 AVG(score) -- = (80 + 90) / 2 = 85.0 ← 分母は 2(NULL 除外) COUNT(score) -- = 2 (NULL を除いた非 NULL 件数) COUNT(*) -- = 4 (全件数) COUNT(*) - COUNT(score) -- = 2 (NULL 件数 = 未回答数) -- NULL を 0 として全員を平均に含めたい場合 AVG(COALESCE(score, 0)) -- = (80 + 0 + 90 + 0) / 4 = 42.5
AVG(score) は回答者(非 NULL)の平均、AVG(COALESCE(score, 0)) は未回答を 0 点とした全員の平均です。どちらが正しいかはビジネス要件によって変わります。常に COUNT(*) と COUNT(col) をセットで確認し、欠損率を把握することが重要です。survey テーブルから、各カテゴリの平均スコア(avg_score)・回答者数(responded)・未回答者数(not_responded)・回答率(%)(response_rate)を計算してください。出力列は category, avg_score, responded, not_responded, response_rate、category 昇順で返してください。avg_score・response_rate は小数第1位まで丸めてください。
| respondent_id | category | score | submitted_at |
|---|---|---|---|
| 1 | A | 80 | 2024-01-01 |
| 2 | A | NULL | NULL |
| 3 | A | 90 | 2024-01-02 |
| 4 | B | 70 | 2024-01-01 |
| 5 | B | NULL | NULL |
| 6 | B | NULL | NULL |
| 7 | B | 60 | 2024-01-03 |
| 8 | A | NULL | NULL |
| category | avg_score | responded | not_responded | response_rate |
|---|---|---|---|---|
| A | 85.0 | 2 | 2 | 50.0 |
| B | 65.0 | 2 | 2 | 50.0 |
SELECT category, ROUND(AVG(score), 1) AS avg_score, -- NULL 除外の平均 COUNT(score) AS responded, -- 非 NULL 件数 COUNT(*) - COUNT(score) AS not_responded,-- NULL 件数 ROUND(COUNT(score) * 100.0 / COUNT(*), 1) AS response_rate -- 回答率 (%) FROM survey GROUP BY category ORDER BY category; /* 実行順序(SQLの論理的な評価順): 1. FROM survey → 行を読込 2. GROUP BY category → カテゴリでグループ化 3. AVG(score) → NULL を除外して平均(回答者平均) 4. COUNT(score) → 回答者数を集計 5. COUNT(*) → 対象者数を集計 6. COUNT(*) - COUNT(score) → 未回答者数を算出 7. COUNT(score) * 100.0 / COUNT(*) → 回答率を算出 8. ROUND / SELECT / ORDER BY category → 丸めて出力 */
LEGEND
① FROM survey(8行)
FROM surveysurvey テーブルの8行を読み込みます。score と submitted_at が NULL の行(respondent_id=2,5,6,8)が未回答者です。この NULL を AVG がどう処理するかが今回のポイントです。| respondent_id | category | score | submitted_at |
|---|---|---|---|
| 1 | A | 80 | 2024-01-01 |
| 2 | A | NULL | NULL |
| 3 | A | 90 | 2024-01-02 |
| 4 | B | 70 | 2024-01-01 |
| 5 | B | NULL | NULL |
| 6 | B | NULL | NULL |
| 7 | B | 60 | 2024-01-03 |
| 8 | A | NULL | NULL |
AVG(score) は NULL を除いた行の合計 ÷ 非 NULL 行数で計算します。4行のうち 2 行が NULL なら分母は 2 になります。「全員の平均」ではなく「回答者の平均」になることを常に意識してください。NULL が多い列では AVG の分母が大きく変わります。SUM(CASE WHEN score IS NULL THEN 1 ELSE 0 END) と等価ですが、より簡潔です。欠損数を常に把握することが分析品質の基本です。AVG(COALESCE(score, 0)) とすると未回答を 0 点として全員の平均を計算します。ビジネス要件によって「回答者の平均」と「全員の平均(未回答=0点)」を使い分けてください。どちらを使うかはデータ仕様書に明記することが重要です。responded / not_responded / response_rate を必ず avg_score と同じモデルに含める設計がデータ品質管理のベストプラクティスです。LEFT JOIN と NULL — 「一致なし」が NULL を生むアンチジョインパターン
LEFT JOIN は左テーブルの全行を保持し、右テーブルに一致行がない場合は右テーブルの全列を NULL で補完します。この性質を利用して「一致しない行を抽出する」アンチジョインが実現できます。
SELECT c.name, o.order_id FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id -- customer_id=2,4: orders に一致なし → o.order_id = NULL WHERE o.order_id IS NULL -- 一致なし(未購入)の行だけを通過
WHERE right_table.pk IS NULL は右テーブルに対応行が存在しない左テーブルの行を抽出します。NOT IN サブクエリは右テーブルに NULL が含まれると全行除外のリスクがあり、LEFT JOIN + IS NULL がより安全でパフォーマンスも良好なパターンです。customers テーブルと orders テーブルを LEFT JOIN して、一度も注文していない顧客(未購入顧客)を抽出してください。出力列は customer_id, name、customer_id 昇順で返してください。
| customer_id | name |
|---|---|
| 1 | 田中 |
| 2 | 鈴木 |
| 3 | 佐藤 |
| 4 | 伊藤 |
| 5 | 山田 |
| order_id | customer_id | amount |
|---|---|---|
| 1 | 1 | 3000 |
| 2 | 3 | 5000 |
| 3 | 1 | 2000 |
| 4 | 5 | 1500 |
| customer_id | name |
|---|---|
| 2 | 鈴木 |
| 4 | 伊藤 |
SELECT c.customer_id, c.name FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id WHERE o.order_id IS NULL -- 右テーブルが NULL = 一致なし(未購入顧客) ORDER BY c.customer_id; /* 実行順序(SQLの論理的な評価順): 1. FROM customers c → 行を読込 2. LEFT JOIN orders o → 左テーブル全行を保持して結合 3. WHERE o.order_id IS NULL → 右が NULL の行だけ通過(未購入) 4. SELECT c.customer_id, c.name / ORDER BY → customer_id 昇順で出力 */
LEGEND
① FROM customers(5行)
FROM customers ccustomers テーブルの5行を読み込みます。この全行に対して LEFT JOIN を行い、orders に注文記録がない顧客を NULL で検出します。| customer_id | name |
|---|---|
| 1 | 田中 |
| 2 | 鈴木 |
| 3 | 佐藤 |
| 4 | 伊藤 |
| 5 | 山田 |
WHERE right_table.pk IS NULL でこの「一致なし」行だけを抽出するパターンが「アンチジョイン(Anti-Join)」です。未購入顧客・未フォロー顧客・未完了タスクなど「〜していないもの」の抽出に広く使われます。WHERE customer_id NOT IN (SELECT customer_id FROM orders) は、orders.customer_id に NULL が1件でも含まれると NOT IN (..., NULL, ...) が UNKNOWN を返し、全行が除外されます。NOT IN の代わりに NOT EXISTS か LEFT JOIN + IS NULL を使うことで、この NULL トラップを安全に回避できます。WHERE o.status = 'paid' を LEFT JOIN 後に書くと、o.status が NULL の行(一致なし行)は NULL = 'paid' が UNKNOWN になり除外されます。右テーブルへの絞り込みは ON 句に書く(結合条件の一部にする)か、サブクエリで事前に絞り込んでから LEFT JOIN するのが正しい設計です。LEFT JOIN orders o ON ... WHERE o.status = 'paid' と書くと、NULL 行(一致なし行)が NULL = 'paid' → UNKNOWN で除外され、実質 INNER JOIN になります。RIGHT テーブルへのフィルタは ON c.id = o.customer_id AND o.status = 'paid' と ON 句に含めてください。ON 句に書けば一致なし行は NULL のまま保持されます。WHERE o.order_id = NULL と書くと等値比較が UNKNOWN になり、結果は常に0件になります。RIGHT テーブルの NULL 検出は必ず IS NULL を使ってください。これは IS NULL の鉄則がアンチジョインパターンでも同様に適用される例です。