EXISTS述語 — 相関サブクエリで「1件でも存在するか」を判定する
EXISTS 述語はサブクエリが1行以上の結果を返せば TRUE、0件なら FALSE を返します。通常、外側クエリの列を内側サブクエリに持ち込む相関サブクエリ(Correlated Subquery)と組み合わせて使います。
WHERE EXISTS ( SELECT 1 -- SELECT の中身は評価されない(1 / * / NULL でも同じ) FROM orders o WHERE o.user_id = u.user_id -- 外側の u を内側に持ち込む「相関条件」 AND o.status = 'completed' )
COUNT(*) > 0 より大幅に効率的です。users テーブルから、completed(完了済み)の注文を 1 件以上持つユーザーを取得してください。取得列は user_id, name, plan、user_id 昇順で返してください。
| user_id | name | plan |
|---|---|---|
| 1 | 田中太郎 | premium |
| 2 | 佐藤花子 | free |
| 3 | 鈴木一郎 | premium |
| 4 | 山田次郎 | free |
| 5 | 高橋三郎 | free |
| order_id | user_id | amount | status | ordered_at |
|---|---|---|---|---|
| 101 | 1 | 15000 | completed | 2024-05-01 |
| 102 | 1 | 8000 | cancelled | 2024-05-10 |
| 103 | 2 | 5000 | pending | 2024-05-15 |
| 104 | 3 | 22000 | completed | 2024-05-20 |
| 105 | 4 | 3000 | cancelled | 2024-05-22 |
| 106 | 3 | 9000 | completed | 2024-05-25 |
| 107 | 5 | 12000 | completed | 2024-05-28 |
| user_id | name | plan |
|---|---|---|
| 1 | 田中太郎 | premium |
| 3 | 鈴木一郎 | premium |
| 5 | 高橋三郎 | free |
SELECT u.user_id, u.name, u.plan FROM users u WHERE EXISTS ( SELECT 1 -- SELECT * / 'x' / NULL でも動作は同じ FROM orders o WHERE o.user_id = u.user_id -- 相関条件: 外側の u を内側に持ち込む AND o.status = 'completed' ) ORDER BY u.user_id; /* 実行順序(SQLの論理的な評価順): 1. FROM users u → 外側 5行をスキャン 2. [各行に対して] EXISTS 相関サブクエリ → completed 注文を短絡評価 3. WHERE EXISTS (...) → TRUE の行を通過 4. SELECT u.user_id, u.name, u.plan → 3列を選択 5. ORDER BY u.user_id → user_id 昇順 */
LEGEND
① FROM users u
FROM users uusersテーブル全5行を外側テーブルとして読み込みます。この後、各ユーザー行に対してEXISTSの相関サブクエリが個別に実行されます。| user_id | name | plan |
|---|---|---|
| 1 | 田中太郎 | premium |
| 2 | 佐藤花子 | free |
| 3 | 鈴木一郎 | premium |
| 4 | 山田次郎 | free |
| 5 | 高橋三郎 | free |
COUNT(*) > 0 より大幅に効率的です。EXISTS (SELECT 1 ...) の SELECT 句の値は一切評価されません。SELECT * / SELECT NULL / SELECT 'x' でも動作は完全に同じです。慣習として SELECT 1 が最もよく使われます。WHERE o.user_id = u.user_id が「外側テーブルの列を内側サブクエリに持ち込む相関条件」です。この条件がないと内側クエリが全行を走査して常に TRUE になり、すべての外側行が返ってしまいます。user_id IN (SELECT user_id FROM ...) と EXISTS は等価です。現代のオプティマイザはどちらも同様の実行計画に変換することが多く、相関条件がある場合は EXISTS の方が自然な表現です(Q3参照)。WHERE EXISTS (...) と書くだけで十分です。WHERE EXISTS (SELECT 1 FROM orders WHERE status = 'completed') は orders テーブルに完了注文が1件でもあれば全ユーザーが返るバグです。必ず o.user_id = u.user_id のような相関条件を追加してください。INNER JOIN との違いは重複の扱いで、1ユーザーが複数の注文を持つ場合 JOIN だと同じユーザーが複数行返ってしまいます(DISTINCT が必要)。EXISTS は重複を自動的に排除するため、「〜を持つユーザー」「〜が存在する行を取得する」には常に EXISTS が JOIN より適切です。NOT EXISTS述語 — アンチジョインパターンと NOT IN の NULL 罠
NOT EXISTS は「対応する行が1件も存在しない」行を返します。INNER JOIN の逆にあたるアンチジョイン(Anti-Join)パターンで使われます。
WHERE NOT EXISTS ( SELECT 1 FROM order_items oi WHERE oi.product_id = p.product_id -- 1件でもあれば FALSE、なければ TRUE )
x <> NULL = UNKNOWN となり、AND 全体が UNKNOWN → 全行除外されます。NOT EXISTS はこの罠を回避します。products テーブルから、一度も注文されたことがない商品を取得してください。order_items テーブルとの照合に NOT EXISTS を使い、取得列は product_id, name, category, price、product_id 昇順で返してください。
| product_id | name | category | price |
|---|---|---|---|
| 1 | スマートフォン X | スマートフォン | 89800 |
| 2 | スマートブック Pro | PC | 128000 |
| 3 | タブレット Air | タブレット | 64800 |
| 4 | ワイヤレスイヤホン | アクセサリ | 12800 |
| 5 | USBケーブル | アクセサリ | 980 |
| 6 | ゲーミングPC Pro | PC | 198000 |
| item_id | order_id | product_id | quantity |
|---|---|---|---|
| 1 | 101 | 1 | 1 |
| 2 | 101 | 4 | 2 |
| 3 | 104 | 2 | 1 |
| 4 | 106 | 3 | 1 |
| 5 | 107 | 1 | 1 |
| 6 | 105 | NULL | 1 |
| product_id | name | category | price |
|---|---|---|---|
| 5 | USBケーブル | アクセサリ | 980 |
| 6 | ゲーミングPC Pro | PC | 198000 |
SELECT p.product_id, p.name, p.category, p.price FROM products p WHERE NOT EXISTS ( SELECT 1 FROM order_items oi WHERE oi.product_id = p.product_id -- 相関条件: 1件でもあれば FALSE ) ORDER BY p.product_id; -- ✗ 誤った書き方(order_items.product_id に NULL が含まれると 0件になる): -- WHERE p.product_id NOT IN (SELECT product_id FROM order_items) /* 実行順序(SQLの論理的な評価順): 1. FROM products p → 外側: 6行スキャン 2. [各行に対して] NOT EXISTS 相関サブクエリ: FROM order_items oi WHERE oi.product_id = p.product_id → 1件でもヒット → FALSE(除外) 0件 → TRUE(通過) 3. WHERE NOT EXISTS (...) → TRUE の 2行を通過(product 5, 6) 4. SELECT p.product_id, p.name, ... → 4列を選択 5. ORDER BY p.product_id → ID 昇順 */
LEGEND
① FROM products p
FROM products pproductsテーブル全6行を外側テーブルとして読み込みます。一度も注文されていない商品を見つけます。| product_id | name | category | price |
|---|---|---|---|
| 1 | スマートフォン X | スマートフォン | 89800 |
| 2 | スマートブック Pro | PC | 128000 |
| 3 | タブレット Air | タブレット | 64800 |
| 4 | ワイヤレスイヤホン | アクセサリ | 12800 |
| 5 | USBケーブル | アクセサリ | 980 |
| 6 | ゲーミングPC Pro | PC | 198000 |
x NOT IN (..., NULL) は x <> a AND x <> b AND ... AND x <> NULL に展開されます。x <> NULL は常に UNKNOWN → AND 全体が UNKNOWN → 全行除外。これは最も見逃されやすい実務バグのひとつです。NOT EXISTS (...)、②NOT IN (SELECT ... WHERE col IS NOT NULL)(NULL除外が必須)、③LEFT JOIN ... WHERE right_key IS NULL、の3つは同じ結果を返します。推奨は可読性と安全性の高い NOT EXISTS です。WHERE product_id NOT IN (SELECT product_id FROM order_items) は product_id に NULL が1行でも含まれると全件 0件になります。常に NOT EXISTS か、内側に WHERE col IS NOT NULL を追加した NOT IN を使いましょう。NOT IN (サブクエリ) は NULL が保証されていない限り使わないようにしましょう。大量テーブルでは NOT EXISTS + 外部キーのインデックスが最も効率的なケースが多いです。IN(サブクエリ)述語 — 動的集合によるセミジョインと NOT IN の NULL 罠
IN (サブクエリ) はサブクエリが返す集合に列の値が含まれるかを評価します。固定リストの代わりにテーブルの現在状態から動的に集合を生成できるため、実務で頻繁に使われます。
WHERE o.user_id IN ( SELECT user_id -- サブクエリで「プレミアム会員のIDリスト」を動的生成 FROM users WHERE plan = 'premium' ) -- ↑ 非相関サブクエリ: 1回だけ評価されて集合を展開、EXISTS と等価になることが多い
NOT IN は全行 UNKNOWN → 0件になります。NOT IN は必ず NOT EXISTS か WHERE col IS NOT NULL 付きで使いましょう。orders テーブルから、プレミアム会員(plan = 'premium')が行った注文を取得してください。users テーブルへのサブクエリを使い、取得列は order_id, user_id, amount, status, ordered_at、ordered_at 昇順で返してください。
| order_id | user_id | amount | status | ordered_at |
|---|---|---|---|---|
| 101 | 1 | 15000 | completed | 2024-05-01 |
| 102 | 1 | 8000 | cancelled | 2024-05-10 |
| 103 | 2 | 5000 | pending | 2024-05-15 |
| 104 | 3 | 22000 | completed | 2024-05-20 |
| 105 | 4 | 3000 | cancelled | 2024-05-22 |
| 106 | 3 | 9000 | completed | 2024-05-25 |
| 107 | 5 | 12000 | completed | 2024-05-28 |
| user_id | name | plan |
|---|---|---|
| 1 | 田中太郎 | premium |
| 2 | 佐藤花子 | free |
| 3 | 鈴木一郎 | premium |
| 4 | 山田次郎 | free |
| 5 | 高橋三郎 | free |
| order_id | user_id | amount | status | ordered_at |
|---|---|---|---|---|
| 101 | 1 | 15000 | completed | 2024-05-01 |
| 102 | 1 | 8000 | cancelled | 2024-05-10 |
| 104 | 3 | 22000 | completed | 2024-05-20 |
| 106 | 3 | 9000 | completed | 2024-05-25 |
SELECT o.order_id, o.user_id, o.amount, o.status, o.ordered_at FROM orders o WHERE o.user_id IN ( SELECT user_id -- プレミアム会員の user_id を動的に取得 FROM users WHERE plan = 'premium' -- サブクエリ結果: {1, 3} ) ORDER BY o.ordered_at; /* 実行順序(SQLの論理的な評価順): 1. サブクエリ評価(非相関) → premium ユーザーを1回だけ取得 2. FROM orders o → 7行読み込み 3. WHERE o.user_id IN (...) → 該当ユーザーの注文に絞り込み 4. SELECT o.order_id, o.user_id, ... → 5列を選択 5. ORDER BY o.ordered_at → 日付昇順 */
LEGEND
① サブクエリ実行(動的集合の生成)
SELECT user_id FROM users WHERE plan = 'premium'INの内部にある非相関サブクエリがまず1回だけ実行され、プレミアム会員のIDリスト(動的集合)を生成します。| user_id | name | plan | IN集合の要素 |
|---|---|---|---|
| 1 | 田中太郎 | premium | ✓ 追加 (1) |
| 2 | 佐藤花子 | free | ✗ 除外 |
| 3 | 鈴木一郎 | premium | ✓ 追加 (3) |
| 4 | 山田次郎 | free | ✗ 除外 |
| 5 | 高橋三郎 | free | ✗ 除外 |
IN (SELECT ...) は内側を1回だけ評価して集合を生成し、外側クエリの各行と照合します。実行計画上では Hash Semi Join / Merge Semi Join として現れることが多いです。WHERE user_id IN (...) と WHERE EXISTS (...) は等価です。現代のオプティマイザは相互変換を行うため、どちらも同様の実行計画になることが多く、可読性で選んで問題ありません。NOT IN (SELECT nullable_col ...) は NULL が含まれると全件 0件になります。動的なサブクエリでも固定リストでも同じ罠があります。NOT IN を使う場合は必ず WHERE col IS NOT NULL を内側に追加するか、NOT EXISTS に切り替えましょう。WHERE id IN (1, 2, ..., 50000) と埋め込むと、SQL のパース・プランニングコストが激増します。一時テーブル / UNNEST / JOIN など、大量集合に特化した設計に切り替えましょう。WHERE user_id = ANY($1::int[]) に配列を1つのバインド変数として渡せます(任意の長さで安全)。Node.js / Prisma / Drizzle などの ORM も内部でこのパターンを使用します。固定リストの手書き IN はアプリの状態と乖離しやすいため、テーブルの現在状態に基づく IN (subquery) パターンへの切り替えを検討しましょう。ALL / ANY述語 — サブクエリ結果の全体・部分との比較(∀ と ∃)
ANY(= SOME)はサブクエリの集合の少なくとも1件に対して比較が真、ALL はすべての件に対して比較が真のときに TRUE を返します。
WHERE price > ANY (SELECT price FROM products WHERE category = 'アクセサリ') -- ↑ price > MIN(アクセサリ価格) と等価: 「いずれかより高い」= 集合内の最小値より大きい WHERE price > ALL (SELECT price FROM products WHERE category = 'アクセサリ') -- ↑ price > MAX(アクセサリ価格) と等価: 「すべてより高い」= 集合内の最大値より大きい
category = ANY (ARRAY['PC', 'タブレット']) は IN ('PC', 'タブレット') と完全に等価です。比較演算子に > や < を使った場合が ANY / ALL の真価です。products テーブルから、アクセサリカテゴリのすべての商品より高額な商品を ALL 述語を使って取得してください。取得列は product_id, name, category, price、price 昇順で返してください。
| product_id | name | category | price | stock |
|---|---|---|---|---|
| 1 | スマートフォン X | スマートフォン | 89800 | 50 |
| 2 | スマートブック Pro | PC | 128000 | 0 |
| 3 | タブレット Air | タブレット | 64800 | 30 |
| 4 | ワイヤレスイヤホン | アクセサリ | 12800 | 100 |
| 5 | USBケーブル | アクセサリ | 980 | 200 |
| 6 | ゲーミングPC Pro | PC | 198000 | 5 |
| product_id | name | category | price |
|---|---|---|---|
| 3 | タブレット Air | タブレット | 64800 |
| 1 | スマートフォン X | スマートフォン | 89800 |
| 2 | スマートブック Pro | PC | 128000 |
| 6 | ゲーミングPC Pro | PC | 198000 |
SELECT product_id, name, category, price FROM products WHERE price > ALL ( SELECT price -- アクセサリ全商品の価格リスト: {12800, 980} FROM products WHERE category = 'アクセサリ' ) ORDER BY price; -- ↑ price > MAX(アクセサリ価格) = price > 12800 と等価 /* 実行順序(SQLの論理的な評価順): 1. サブクエリ評価 → アクセサリの価格集合を生成 2. FROM products → 6行読み込み 3. WHERE price > ALL (...) → 最大値超で絞り込み 4. SELECT product_id, name, ... → 4列を選択 5. ORDER BY price → 価格昇順 */
LEGEND
① サブクエリ実行(比較用集合の生成)
SELECT price FROM products WHERE category = 'アクセサリ'サブクエリを実行して、比較対象となる「アクセサリ」の価格集合を取得します。| product_id | name | category | price(集合に追加) |
|---|---|---|---|
| 4 | ワイヤレスイヤホン | アクセサリ | 12800 |
| 5 | USBケーブル | アクセサリ | 980 |
price > ANY (集合) は「集合の中の少なくとも1つより price が大きい」→ price > MIN(集合) と等価です。また = ANY は IN と完全に等価です。price > ALL (集合) は「集合のすべての要素より price が大きい」→ price > MAX(集合) と等価です。また <> ALL は NULL なしの前提で NOT IN と等価です。ANY(空集合) は常に FALSE(比較できる要素がない)、ALL(空集合) は常に TRUE(空虚な真: Vacuous Truth)です。ALL のサブクエリが空になりうる場合は注意が必要です。price > ALL(空集合) は常に TRUE となり全商品が返ります。WHERE 条件が意図と逆になるバグです。サブクエリが空になりうる場合は EXISTS で事前チェックするか、COALESCE でデフォルト値を設けましょう。price > ANY (...) は price > (SELECT MIN(price) ...) と等価ですが、データベースによってはスカラーサブクエリの方がインデックス活用の最適化が容易です。EXPLAIN で確認して遅い場合は MIN / MAX への書き換えを検討しましょう。col = ANY($1::int[]) で配列型パラメータを直接渡せます(IN のリストを配列で渡す最もクリーンな方法)。一方 = ANY(サブクエリ) と IN(サブクエリ) はオプティマイザが同一視するため、可読性の高い IN を使うのが一般的です。> ANY / > ALL のような比較演算子との組み合わせは、MIN / MAX サブクエリへ明示的に書き直すとクエリの意図が伝わりやすくなります。IS DISTINCT FROM述語 — NULL安全な等価比較と <> の落とし穴
IS DISTINCT FROM は NULL を含む等価比較を安全に行う述語です。通常の <> は NULL との比較が UNKNOWN になり行が除外されますが、IS DISTINCT FROM は NULL を「明確に異なる値」として TRUE を返します。
-- a IS DISTINCT FROM b の真理値表 'WINTER20' IS DISTINCT FROM 'SUMMER10' -- → TRUE (値が異なる) NULL IS DISTINCT FROM 'SUMMER10' -- → TRUE (NULL は「異なる」扱い) 'SUMMER10' IS DISTINCT FROM 'SUMMER10' -- → FALSE (同じ値) NULL IS DISTINCT FROM NULL -- → FALSE (両方 NULL は「同じ」扱い)
coupon_code <> 'SUMMER10' では coupon_code が NULL の行は UNKNOWN → 除外されます。「クーポン未使用(NULL)の行も含めたい」場合は IS DISTINCT FROM を使います。orders テーブルから、coupon_code が 'SUMMER10' とは異なる注文(クーポン未使用の NULL 行も含む)を取得してください。IS DISTINCT FROM を使い、取得列は order_id, user_id, amount, coupon_code, ordered_at、order_id 昇順で返してください。
| order_id | user_id | amount | coupon_code | ordered_at |
|---|---|---|---|---|
| 101 | 1 | 15000 | SUMMER10 | 2024-05-01 |
| 102 | 1 | 8000 | NULL | 2024-05-10 |
| 103 | 2 | 5000 | NULL | 2024-05-15 |
| 104 | 3 | 22000 | WINTER20 | 2024-05-20 |
| 105 | 4 | 3000 | NULL | 2024-05-22 |
| 106 | 3 | 9000 | NULL | 2024-05-25 |
| 107 | 5 | 12000 | SUMMER10 | 2024-05-28 |
| order_id | user_id | amount | coupon_code | ordered_at |
|---|---|---|---|---|
| 102 | 1 | 8000 | NULL | 2024-05-10 |
| 103 | 2 | 5000 | NULL | 2024-05-15 |
| 104 | 3 | 22000 | WINTER20 | 2024-05-20 |
| 105 | 4 | 3000 | NULL | 2024-05-22 |
| 106 | 3 | 9000 | NULL | 2024-05-25 |
SELECT order_id, user_id, amount, coupon_code, ordered_at FROM orders WHERE coupon_code IS DISTINCT FROM 'SUMMER10' -- ↑ NULL安全比較: NULL は「SUMMER10 と異なる」= TRUE として扱う -- ✗ coupon_code <> 'SUMMER10' はNULL行がUNKNOWN→除外されてしまう ORDER BY order_id; /* 実行順序(SQLの論理的な評価順): 1. FROM orders → 7行読み込み 2. WHERE coupon_code IS DISTINCT FROM 'SUMMER10' → 一致以外を通過 3. SELECT order_id, user_id, amount, ... → 5列を選択 4. ORDER BY order_id → order_id 昇順 ▸ IS DISTINCT FROM 真理値表(再掲): a = b → FALSE (同値) a ≠ b → TRUE (異なる値) NULL vs 非NULL → TRUE (NULLは「異なる」) NULL vs NULL → FALSE (両方NULLは「同じ」) */
LEGEND
① FROM orders
FROM ordersordersテーブル全7行を読み込みます。coupon_code列にはNULL(クーポン未使用)が含まれています。| order_id | user_id | coupon_code | ordered_at |
|---|---|---|---|
| 101 | 1 | SUMMER10 | 2024-05-01 |
| 102 | 1 | NULL | 2024-05-10 |
| 103 | 2 | NULL | 2024-05-15 |
| 104 | 3 | WINTER20 | 2024-05-20 |
| 105 | 4 | NULL | 2024-05-22 |
| 106 | 3 | NULL | 2024-05-25 |
| 107 | 5 | SUMMER10 | 2024-05-28 |
<> と違い、2値(TRUE/FALSE)を常に返します。NULL <> 'SUMMER10' は UNKNOWN を返し WHERE 句で除外されます(三値論理 基礎編Q4参照)。NULL IS DISTINCT FROM 'SUMMER10' は TRUE を返しその行を含めます。NULL を「値が設定されていない = 比較対象と異なる」とみなしたい場合は IS DISTINCT FROM を使います。COALESCE(coupon_code, '') <> 'SUMMER10' でも NULL 行を通過させられますが、デフォルト値が比較値と同じ場合に意図しない結果になるリスクがあります。IS DISTINCT FROM の方が汎用的で安全です。<> を使うと NULL 行は UNKNOWN となりフィルタから漏れます。「X でもなく NULL でもない行を取得」のつもりが NULL 行が除外される、というのは本番環境で最も発見されにくいバグのひとつです。COALESCE(coupon_code, 'SUMMER10') <> 'SUMMER10' とすると、NULL 行が 'SUMMER10' に変換されて除外されてしまいます(意図と逆)。COALESCE を使う場合はデフォルト値を比較値と衝突しない値にする必要があります。<=>(NULL-safe equal)があり NOT (a <=> b) が IS DISTINCT FROM と等価です。SQL Server 2022 以降は IS DISTINCT FROM が使えますが、それ以前は CASE WHEN a = b OR (a IS NULL AND b IS NULL) THEN 0 ELSE 1 END = 1 のような冗長な書き方が必要でした。対象 DB に合わせて適切な代替構文を用意しておきましょう。