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の暗殺トラップ:= NULL と NOT 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'; -- 👈 結合条件に含めることで、注文のないユーザーも残る
- 参考URL: MySQL公式: LEFT JOINの最適化
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 TRUEやIS 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)は内部的に全く同じ最速の実行計画(インデックスの走査など)に変換されます。
- 参考URL: PostgreSQL公式: 集約関数
5. SQLの「書く順」と「動く順」の不一致によるエラー
SQLは SELECT から書き始めますが、データベース内部で処理される順番は FROM や WHERE の方が先です。この実行順序を誤解していると、エラーや予期せぬ挙動に悩まされます。
❌ アンチパターン
-- SELECT句で定義した別名(エイリアス)を、WHERE句で使い回そうとする
SELECT price * 1.1 AS tax_price
FROM items
WHERE tax_price > 1000; -- 👈 エラー!この時点で「tax_price」はまだ存在しない
⭕ 改善策・実行順序の理解
実際のデータベースの実行順序は以下の通りです。
FROM(データの対象テーブルを決める)WHERE(行を絞り込む) 👈 ここでSELECTの別名は使えない!GROUP BY(グループ化する)HAVING(グループ化した結果をさらに絞り込む)SELECT(画面に出力する列を決定・計算する) 👈 ここで初めて別名が定義されるORDER BY(並び替える) 👈 ここではSELECTの別名が使える!
したがって、WHERE 句にはエイリアスではなく計算式をそのまま書くか、サブクエリ(またはCTE)を使って一度外に出す必要があります。
まとめ
SQLのミスは、プログラム言語のコンパイルエラーとは異なり、「エラーにはならないが、微妙に計算結果(件数)が間違っている」というタチの悪いバグになりがちです。
NULLが絡むときはIS句やNOT EXISTSを意識するLEFT JOINのときは「主軸のデータを残したいか」で条件の位置を決める- 件数カウントは原則
COUNT(*)を使う
これらを意識するだけで、実務でのデータ不整合やレビューでの指摘を大幅に減らすことができます。ぜひ日々のクエリ作成に役立ててください!
※この記事はAIを用いて生成しています。