SQLは直感的に書ける反面、データベース内部の仕組みや「3値論理」を正しく理解していないと、静かにバグを生み出す原因になります。本記事では、特に実務で遭遇しがちな5つのアンチパターンを、具体的な解説と対策を交えて紹介します。

今回扱うデータ構造のイメージ(ER図)は以下の通りです。

erDiagram
    users {
        int id PK
        varchar name
        boolean is_subscribed
    }
    orders {
        int id PK
        int user_id FK
        varchar status
    }
    opt_out_details {
        int id PK
        int user_id FK
        text reason
    }
    users ||--o{ orders : "1対多"
    users ||--o| opt_out_details : "1対1"

1. NULLの暗殺トラップ:= NULLNOT IN

SQLにおいて NULL は「値」ではなく「状態(不明・存在しない)」です。これを通常の比較演算子で扱おうとすると、クエリは沈黙(0件ヒット)します。

❌ アンチパターン

-- 1. NULLを=で比較してしまう(常に偽)
SELECT * FROM users WHERE is_subscribed = NULL;

-- 2. NOT IN の中に NULL が混ざる(結果が常に空になる)
SELECT * FROM users 
WHERE id NOT IN (SELECT user_id FROM orders); -- 👈 orders.user_id に1つでもNULLがあると0件に

⭕ 改善策

NULL の判定には IS NULL を使い、複数条件の否定には NOT EXISTS を使用します。

-- 1. IS NULL を使う
SELECT * FROM users WHERE is_subscribed IS NULL;

-- 2. NOT EXISTS を使う(NULLが含まれていても安全に機能する)
SELECT * FROM users u
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id
);

🌐 データベース別の注釈

  • Oracle Database: 空文字('')を NULL として扱うという独自の仕様があります。そのため、他DBからの移行時に文字列比較でバグが頻発する原因になります。
  • MySQL / PostgreSQL: 空文字('')と NULL は明確に区別されます。

2. 外部結合(LEFT JOIN)を無効化する WHERE 句の罠

「左側のテーブルを全件残したい」から LEFT JOIN を使ったにもかかわらず、その後の WHERE 句で結合先(右側)のテーブルを絞り込んでしまい、実質的に INNER JOIN に化けてしまうミスです。

❌ アンチパターン

SELECT u.name, o.id
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status = 'shipped'; -- 👈 注文がないユーザー(o.statusがNULL)がここで全員消し飛ぶ

⭕ 改善策

「右側の特定のデータだけを部分的にくっつけたい(左側は消したくない)」という場合は、その条件を ON 句(結合条件) に記述します。

SELECT u.name, o.id
FROM users u
LEFT JOIN orders o 
  ON u.id = o.user_id 
  AND o.status = 'shipped'; -- 👈 結合条件に含めることで、注文のないユーザーも残る

3. 真理値型(Boolean)における = false の限界

列が NULL を許可している(Nullable)場合、= false= true による判定は NULL を自動的に排除してしまいます。SQLの「3値論理(True / False / Unknown)」が引き起こす罠です。

❌ アンチパターン

-- 「メルマガを購読していない人」を取得したい
SELECT * FROM users WHERE is_subscribed = false;
-- 👈 購読フラグが「NULL(未設定)」のユーザーが結果から消えてしまう(NULL = false の結果がUnknownになるため)

⭕ 改善策

NULL も含めて「true ではない人」を安全に取得したい場合は、IS NOT TRUE または IS FALSE OR IS NULL を明記します。

SELECT * FROM users WHERE is_subscribed IS NOT TRUE;

🌐 データベース別の注釈

  • PostgreSQL: 標準SQLに準拠した本物の Boolean 型(true/false/null)を持っています。
  • MySQL: 実質的に Boolean 型は存在せず、内部的には TINYINT(1) のエイリアス(1 が true、0 が false)として処理されます。ただし IS TRUEIS NOT TRUE といった構文はMySQLでも利用可能です。

4. COUNT(*)COUNT(列名) の混同と「COUNT(1)最強説」の迷信

件数を数える COUNT ですが、「何を対象にしているか」で挙動が変わります。また、「COUNT(1) の方が速い」というのは現代のRDBMSにおいては完全に過去の遺物(迷信)です。

❌ アンチパターン(意図しない集計)

-- 全体の行数を数えたいのに、NULLが含まれる可能性のある列を指定してしまう
SELECT COUNT(email) FROM users; -- 👈 emailがNULLの人が除外されてしまい、実際のユーザー数より少なくなる

⭕ 改善策

  • テーブルの純粋な行数を数えたい場合は、最も標準的で可読性の高い *`COUNT()`** を使用します。
  • 特定の列に値が入っている(Not NULL)行数を数えたい場合のみ、COUNT(列名) を使用します。
構文 NULLの扱い 処理速度の違い(現代)
COUNT(*) NULL も含めてすべての行をカウント 最速(最適化される)
COUNT(1) NULL も含めてすべての行をカウント COUNT(*) と完全に同じ
COUNT(列名) 指定した列が NULL 以外の行のみカウント 列の評価が入る

🌐 データベース別の注釈 現代の主要なRDBMS(MySQL InnoDB, PostgreSQL, SQL Server, Oracle)のオプティマイザは非常に賢いため、COUNT(*)COUNT(1) は内部的に全く同じ最速の実行計画(インデックスの走査など)に変換されます。


5. SQLの「書く順」と「動く順」の不一致によるエラー

SQLは SELECT から書き始めますが、データベース内部で処理される順番は FROMWHERE の方が先です。この実行順序を誤解していると、エラーや予期せぬ挙動に悩まされます。

❌ アンチパターン

-- SELECT句で定義した別名(エイリアス)を、WHERE句で使い回そうとする
SELECT price * 1.1 AS tax_price 
FROM items 
WHERE tax_price > 1000; -- 👈 エラー!この時点で「tax_price」はまだ存在しない

⭕ 改善策・実行順序の理解

実際のデータベースの実行順序は以下の通りです。

  1. FROM (データの対象テーブルを決める)
  2. WHERE (行を絞り込む) 👈 ここでSELECTの別名は使えない!
  3. GROUP BY (グループ化する)
  4. HAVING (グループ化した結果をさらに絞り込む)
  5. SELECT (画面に出力する列を決定・計算する) 👈 ここで初めて別名が定義される
  6. ORDER BY (並び替える) 👈 ここではSELECTの別名が使える!

したがって、WHERE 句にはエイリアスではなく計算式をそのまま書くか、サブクエリ(またはCTE)を使って一度外に出す必要があります。


まとめ

SQLのミスは、プログラム言語のコンパイルエラーとは異なり、「エラーにはならないが、微妙に計算結果(件数)が間違っている」というタチの悪いバグになりがちです。

  • NULL が絡むときは IS 句や NOT EXISTS を意識する
  • LEFT JOIN のときは「主軸のデータを残したいか」で条件の位置を決める
  • 件数カウントは原則 COUNT(*) を使う

これらを意識するだけで、実務でのデータ不整合やレビューでの指摘を大幅に減らすことができます。ぜひ日々のクエリ作成に役立ててください!

※この記事はAIを用いて生成しています。