EXISTSとINの違いとは、「サブクエリが1行でも返すか(EXISTS)」で判定するか、「値がサブクエリの結果リストに含まれるか(IN)」で判定するかの違いです。 肯定形(IN / EXISTS)では、現在のPostgreSQLとMySQLはどちらもほぼ同じ実行計画に書き換えるため、速度差はほとんど出ません。差がはっきり出るのは否定形で、NOT IN はサブクエリにNULLが1つでも混ざると結果が0件になり、条件によっては実行計画も大きく変わります。

この記事では、PostgreSQL 16.13 と MySQL 8.0.46 に同じ10万件(会員)+20万件(注文)のデータを入れ、IN / EXISTS / JOIN / NOT IN / NOT EXISTS / LEFT JOIN IS NULL の6通りを EXPLAIN ANALYZE の実行結果つき で比較しました。「どちらが速いか」を言い伝えではなく実行計画で確かめ、NOT IN が 2ms → 33秒になる条件、NULLで0件になる再現SQLと直し方、迷ったときの判断表までまとめています。

EXISTSとINの違いの早見表

観点 IN (サブクエリ) EXISTS (サブクエリ) JOIN
判定の仕方 値が結果リストに含まれるか サブクエリが1行以上返すか 結合条件に合う行をすべて連結
サブクエリのSELECT句 比較する列(1列。行値なら同じ列数) 何でもよい(SELECT 1 が定番) ―
外側との関係 非相関で書くのが普通 相関(外側の列を参照)で書くのが普通 ―
外側の行の重複 増えない 増えない 子の件数ぶん増える
サブクエリ側の列を表示 できない できない できる
肯定形の速度(PG16・MySQL8.0) EXISTSとほぼ同じ計画 INとほぼ同じ計画 DISTINCTが要る分の差
否定形 NOT IN:NULLが混ざると0件 NOT EXISTS:NULLの影響なし LEFT JOIN … IS NULL:NULLの影響なし
否定形の実行計画(PG16) anti join にならない(hashed SubPlan) anti join anti join 相当
向いている場面 固定値リスト、NOT NULL列の非相関サブクエリ 存在チェック全般、否定形 子テーブルの列も取りたいとき

迷ったら「肯定形は読みやすい方、否定形は NOT EXISTS」 と覚えておけば、結果の誤りも性能の大きな差も避けられます。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「SQLのEXISTSとINの違い|NULLの罠と実行計画で見る使い分け」

サブクエリの種類を早見表で整理する

EXISTSとINの話に入る前に、サブクエリ(SQLの中に書くSELECT文)の種類を整理します。INとEXISTSは「表サブクエリ」を条件に使う書き方 です。

種類 返すもの 主な置き場所 例 2行以上返したとき
スカラーサブクエリ 1行1列 SELECT句、WHERE句の比較 (SELECT max(amount) FROM orders) エラー
行サブクエリ 1行・複数列 (a, b) = (…) の比較 WHERE (id, name) = (SELECT 1, '佐藤') エラー
表サブクエリ 複数行 IN / EXISTS / ANY / ALL / FROM句 WHERE id IN (SELECT member_id FROM orders) 正常
相関サブクエリ 外側の1行ごとに評価 EXISTS、SELECT句 WHERE EXISTS (SELECT 1 FROM orders o WHERE o.member_id = m.id) 置き場所の種類に従う

「相関」は 外側のクエリの列(m.id など)をサブクエリの中で参照している という意味です。論理的には外側の1行ごとにサブクエリを評価しますが、実際にはオプティマイザが結合に書き換えることが多く、「相関サブクエリ=遅い」とは限りません(後の実測で確認します)。

スカラーサブクエリが2行以上を返したときのエラーは次のとおりです(後述のサンプルデータで実行)。

-- PostgreSQL 16
ERROR:  more than one row returned by a subquery used as an expression
-- MySQL 8.0
ERROR 1242 (21000) at line 1: Subquery returns more than 1 row

IN の左右で列数が合わないときは、PostgreSQL では ERROR: subquery has too many columns になります。

検証に使ったテーブルとデータ(コピペ用)

小さなデータで動きを確かめ、大きなデータで実行計画を比べます。まずは結果が目で追える4件のデータです。PostgreSQL・MySQL どちらでもそのまま動きます。

CREATE TABLE d_members (id INT PRIMARY KEY, name VARCHAR(20));
CREATE TABLE d_orders  (id INT PRIMARY KEY, member_id INT, amount INT);

INSERT INTO d_members VALUES (1,'佐藤'),(2,'鈴木'),(3,'高橋'),(4,'田中');
-- 注文104 は会員が未設定(member_id が NULL)
INSERT INTO d_orders VALUES (101,1,1200),(102,1,800),(103,2,3000),(104,NULL,500);

性能比較用は、会員10万件・注文20万件です(注文のある会員は73,381人、注文のない会員は26,619人)。PostgreSQL 16 では次のSQLで作りました。

CREATE TABLE members (id int PRIMARY KEY, name text NOT NULL);
CREATE TABLE orders  (id int PRIMARY KEY, member_id int, amount int NOT NULL);
INSERT INTO members SELECT g, 'member' || g FROM generate_series(1,100000) g;
-- 会員1〜80000の中からハッシュで散らして注文を作る
INSERT INTO orders
SELECT g, (hashint4(g) & 2147483647) % 80000 + 1, (g % 50) * 100
FROM generate_series(1,200000) g;
CREATE INDEX idx_orders_member ON orders(member_id);
VACUUM ANALYZE members;
VACUUM ANALYZE orders;

MySQL 8.0 には同じ構造のテーブル(orders に KEY idx_orders_member (member_id))を作り、注文データは PostgreSQL から \copy orders TO 'orders.csv' CSV で書き出したCSVを LOAD DATA LOCAL INFILE で読み込んで、両DBでまったく同じ行 にそろえています。

INとEXISTSの基本の書き方と結果

「注文したことのある会員」を3通りで取り出します。

-- IN:member_id のリストに含まれるか
SELECT m.id, m.name FROM d_members m
WHERE m.id IN (SELECT o.member_id FROM d_orders o)
ORDER BY m.id;

-- EXISTS:その会員の注文が1件でもあるか(相関サブクエリ)
SELECT m.id, m.name FROM d_members m
WHERE EXISTS (SELECT 1 FROM d_orders o WHERE o.member_id = m.id)
ORDER BY m.id;

実行結果(PostgreSQL 16、どちらも同じ):

 id | name 
----+------
  1 | 佐藤
  2 | 鈴木
(2 rows)

EXISTS のサブクエリのSELECT句は評価されません。 SELECT 1、SELECT *、SELECT NULL のどれでも結果は同じなので、意図が伝わりやすい SELECT 1 がよく使われます。

JOINとの違いは「重複の出方」

同じことを JOIN で書くと、注文の件数ぶん会員が重複します。

SELECT m.id, m.name FROM d_members m
JOIN d_orders o ON o.member_id = m.id
ORDER BY m.id;
 id | name 
----+------
  1 | 佐藤
  1 | 佐藤
  2 | 鈴木
(3 rows)

佐藤さんは注文が2件あるため2行になります。「存在するかどうか」だけを知りたいときは IN / EXISTS、子テーブルの列も一緒に取りたいときは JOIN と使い分けるのが基本です。JOIN で重複を消すには DISTINCT が必要で、その分の並べ替えや集約が実行計画に加わります。JOIN の種類そのものについては、別記事「SQLのJOINの違い」で詳しく扱っています。

NOT INにNULLが混ざると0件になる(再現と直し方)

ここがEXISTSとINの違いで いちばん結果に影響する点 です。「注文したことのない会員」を取り出します。期待する答えは高橋さんと田中さんの2人です。

-- 直す前:NOT IN
SELECT id, name FROM d_members
WHERE id NOT IN (SELECT member_id FROM d_orders);

実行結果(PostgreSQL 16):

 id | name 
----+------
(0 rows)

MySQL 8.0 でも同じく COUNT(*) が 0 になりました。

なぜ0件になるのか

id NOT IN (1, 1, 2, NULL) は id <> 1 AND id <> 1 AND id <> 2 AND id <> NULL と同じ意味です。id <> NULL は TRUE でも FALSE でもなく UNKNOWN(不明) になるため、AND 全体が TRUE になれず、WHERE句を通る行がなくなります。

SELECT 3 NOT IN (1,2,NULL) AS r1, 3 IN (1,2,NULL) AS r2, 1 IN (1,2,NULL) AS r3;
 r1 | r2 | r3 
----+----+----
    |    | t
(1 row)

r1 と r2 は空欄=NULL(UNKNOWN)です。肯定形の IN は「一致するものがあれば TRUE」なので NULL の影響を受けにくい一方、NOT IN は「どれとも一致しない」と言い切れないため結果が消えます。 MySQL でも SELECT 3 NOT IN (1,2,NULL) は NULL を返しました。

直し方は3通り

-- 直し方1:NOT EXISTS(おすすめ)
SELECT id, name FROM d_members m
WHERE NOT EXISTS (SELECT 1 FROM d_orders o WHERE o.member_id = m.id);

-- 直し方2:サブクエリから NULL を除く
SELECT id, name FROM d_members
WHERE id NOT IN (SELECT member_id FROM d_orders WHERE member_id IS NOT NULL);

-- 直し方3:LEFT JOIN … IS NULL
SELECT m.id, m.name FROM d_members m
LEFT JOIN d_orders o ON o.member_id = m.id
WHERE o.id IS NULL;

実行結果(直し方1、PostgreSQL 16):

 id | name 
----+------
  4 | 田中
  3 | 高橋
(2 rows)

直し方2も高橋・田中の2行を返しました。行の順番は ORDER BY を書かない限り保証されない ので、並びを固定したいときは ORDER BY id を付けてください。

直し方 NULLへの強さ 読みやすさ 補足
NOT EXISTS 影響なし 意図がそのまま読める 第一候補。PG・MySQLとも anti join になる
NOT IN + IS NOT NULL 条件を書けば影響なし 書き忘れやすい 列を NOT NULL にできるなら素の NOT IN も可
LEFT JOIN … IS NULL 影響なし 慣れが必要 IS NULL は右表の NOT NULL 列(主キーなど) で判定する

LEFT JOIN 版で WHERE o.member_id IS NULL のように NULL を取りうる列で判定すると、意図と違う行が混ざることがあります。右表の主キーで判定する と安全です。NULL全般の扱いは別記事「SQLのNULLの扱い」にまとめています。

実行計画で比べる:10万件での6パターン実測

ここからは会員10万件・注文20万件のテーブルで、6通りの書き方を EXPLAIN ANALYZE で比べます。どれも件数を数える SELECT count(*) … の形にそろえました。

-- 1. IN
SELECT count(*) FROM members m WHERE m.id IN (SELECT o.member_id FROM orders o);
-- 2. EXISTS
SELECT count(*) FROM members m WHERE EXISTS (SELECT 1 FROM orders o WHERE o.member_id = m.id);
-- 3. JOIN + DISTINCT
SELECT count(DISTINCT m.id) FROM members m JOIN orders o ON o.member_id = m.id;
-- 4. NOT IN
SELECT count(*) FROM members m WHERE m.id NOT IN (SELECT o.member_id FROM orders o);
-- 5. NOT EXISTS
SELECT count(*) FROM members m WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.member_id = m.id);
-- 6. LEFT JOIN IS NULL
SELECT count(*) FROM members m LEFT JOIN orders o ON o.member_id = m.id WHERE o.id IS NULL;

実測結果の比較表

PostgreSQL は EXPLAIN (ANALYZE, FORMAT JSON) の Execution Time、MySQL は EXPLAIN ANALYZE 最上位ノードの actual time を、それぞれ7回計測した中央値です(PostgreSQL は2セット計測した中央値の範囲)。共有のコンテナで計測したため、数十ms程度の揺れは誤差 と見てください。

書き方 件数 PG16 の主な計画 PG16 実行時間 MySQL 8.0 の主な計画 MySQL 実行時間
IN 73,381 Hash Join + HashAggregate(重複除去) 121〜136ms Nested loop inner join + Materialize with deduplication 181ms
EXISTS 73,381 IN と同じ計画 120〜123ms IN と同じ計画 179ms
JOIN + DISTINCT 73,381 Merge Join(Index Only Scan 同士) 63〜68ms Nested loop inner join + count(distinct) 317ms
NOT IN 26,619 Seq Scan + hashed SubPlan 101〜107ms in_optimizer + Materialize(antijoinにならない) 202ms
NOT EXISTS 26,619 Hash Right Anti Join 77〜95ms Nested loop antijoin 176ms
LEFT JOIN IS NULL 26,619 Hash Right Join + Filter 61〜96ms Nested loop antijoin 188ms

この表からわかることは次の3つです。

  • IN と EXISTS は、PG16 でも MySQL 8.0 でも同じ実行計画になった。 書き方の違いは速度の差になっていません。
  • JOIN + DISTINCT は DB によって順位が入れ替わる。 PG16 では両テーブルのインデックスを順に読む Merge Join が効いて最速、MySQL では20万行を結合してから重複を数えるため最も時間がかかりました。
  • 否定形は NOT EXISTS と LEFT JOIN IS NULL が anti join(相手に存在しない行だけを残す結合)になり、NOT IN だけが別の計画になる。

PostgreSQL 16:IN と EXISTS の実行計画

IN の実行計画(VACUUM 前の1回目)です。

 Aggregate (actual time=119.557..119.561 rows=1 loops=1)
   ->  Hash Join (actual time=88.412..115.812 rows=73381 loops=1)
         Hash Cond: (m.id = o.member_id)
         ->  Seq Scan on members m (actual time=0.009..8.277 rows=100000 loops=1)
         ->  Hash (actual time=87.898..87.900 rows=73381 loops=1)
               ->  HashAggregate (actual time=68.290..76.759 rows=73381 loops=1)
                     Group Key: o.member_id
                     ->  Seq Scan on orders o (actual time=0.007..25.077 rows=200000 loops=1)
 Execution Time: 122.021 ms

EXISTS も Hash Join → HashAggregate (Group Key: o.member_id) というまったく同じ形で、Execution Time は 105.816 ms でした。PostgreSQL は IN(サブクエリ)も相関EXISTSも「準結合(semi join)」に書き換え、ここでは「注文側の member_id を重複除去してから普通に結合する」方法を選んでいます。

PostgreSQL 16:NOT IN だけが anti join にならない

-- NOT IN
 Aggregate (actual time=116.558..116.560 rows=1 loops=1)
   ->  Seq Scan on members m (actual time=80.917..114.900 rows=26619 loops=1)
         Filter: (NOT (hashed SubPlan 1))
         Rows Removed by Filter: 73381
         SubPlan 1
           ->  Seq Scan on orders o (actual time=0.010..21.321 rows=200000 loops=1)
 Execution Time: 117.086 ms

-- NOT EXISTS
 Finalize Aggregate (actual time=100.161..104.661 rows=1 loops=1)
   ->  Gather (actual time=91.455..104.620 rows=2 loops=1)
         ->  Partial Aggregate (actual time=87.883..87.887 rows=1 loops=2)
               ->  Parallel Hash Right Anti Join (actual time=83.789..87.025 rows=13310 loops=2)
                     Hash Cond: (o.member_id = m.id)
 Execution Time: 104.746 ms

NOT IN は NULL の扱いが anti join と一致しないため、PostgreSQL は サブクエリの結果をハッシュ表にして1行ずつ照合する「hashed SubPlan」 を使います。ハッシュ表がメモリ(work_mem)に収まる間は十分速いのですが、収まらないと次のように大きく変わります。

差が出る条件1:NOT IN でハッシュ表がメモリに収まらない

work_mem を小さくして、ハッシュ表を作れない状況を再現しました(会員は2,000人に絞っています)。

SET work_mem = '64kB';
EXPLAIN (ANALYZE, COSTS OFF)
SELECT count(*) FROM members m
WHERE m.id NOT IN (SELECT o.member_id FROM orders o) AND m.id <= 2000;
 Aggregate (actual time=33543.443..33543.445 rows=1 loops=1)
   ->  Index Only Scan using members_pkey on members m (actual time=571.885..33542.974 rows=192 loops=1)
         Index Cond: (id <= 2000)
         Filter: (NOT (SubPlan 1))
         Rows Removed by Filter: 1808
         SubPlan 1
           ->  Materialize (actual time=0.005..9.552 rows=74017 loops=2000)
                 ->  Seq Scan on orders o (actual time=0.051..36.221 rows=200000 loops=1)
 Execution Time: 33576.670 ms

同じ条件の NOT EXISTS:

 Aggregate (actual time=2.114..2.115 rows=1 loops=1)
   ->  Nested Loop Anti Join (actual time=0.044..2.098 rows=192 loops=1)
         ->  Index Only Scan using members_pkey on members m (actual time=0.015..0.227 rows=2000 loops=1)
               Index Cond: (id <= 2000)
         ->  Index Only Scan using idx_orders_member on orders o (actual time=0.001..0.001 rows=1 loops=2000)
               Index Cond: (member_id = m.id)
 Execution Time: 2.146 ms

NOT IN は約33.6秒、NOT EXISTS は約2.1ms でした。NOT IN はハッシュを使えないと「外側の1行ごとにサブクエリの結果を先頭から読む(loops=2000)」計画になり、インデックスも使われません。NOT EXISTS は anti join に書き換えられるので、idx_orders_member を1行ごとに引くだけで済みます。サブクエリの結果が大きくなりうる否定条件は NOT EXISTS で書く のが確実です。

差が出る条件2:外側の件数が少ないとき

外側を m.id <= 1000 に絞ると、IN も EXISTS も Nested Loop Semi Join に切り替わり、注文側のインデックスを1行ずつ引く計画になりました。

-- EXISTS(IN も同じ形。IN は 1.090 ms)
 Aggregate (actual time=1.218..1.219 rows=1 loops=1)
   ->  Nested Loop Semi Join (actual time=0.018..1.167 rows=905 loops=1)
         ->  Index Only Scan using members_pkey on members m (actual time=0.013..0.101 rows=1000 loops=1)
               Index Cond: (id <= 1000)
         ->  Index Only Scan using idx_orders_member on orders o (actual time=0.001..0.001 rows=1 loops=1000)
               Index Cond: (member_id = m.id)
 Execution Time: 1.240 ms

相関サブクエリでも、結合キーにインデックスがあれば外側の件数×数マイクロ秒で終わります。 性能を左右しているのは IN か EXISTS かではなく、サブクエリ側の結合キー(ここでは orders.member_id)にインデックスがあるか です。

差が出る条件3:EXISTS を OR でつなぐ

SELECT count(*) FROM members m
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.member_id = m.id) OR m.id <= 10;
 Aggregate (actual time=563.933..563.935 rows=1 loops=1)
   ->  Seq Scan on members m (actual time=479.835..560.268 rows=73381 loops=1)
         Filter: ((hashed SubPlan 2) OR (id <= 10))
 Execution Time: 568.922 ms

OR の中に置いた EXISTS は準結合に書き換えられず、hashed SubPlan になります。 AND でつないだ場合と計画が変わるので、遅いときは UNION に分ける、または条件の組み立てを見直すと改善することがあります。

MySQL 8.0 の実行計画

MySQL 8.0.46 では、IN も EXISTS も次の同じ計画になりました。

-> Aggregate: count(0)  (actual time=173..173 rows=1 loops=1)
    -> Nested loop inner join  (actual time=82.8..170 rows=73381 loops=1)
        -> Covering index scan on m using PRIMARY  (actual time=0.684..17.9 rows=100000 loops=1)
        -> Single-row index lookup on <subquery2> using <auto_distinct_key> (member_id=m.id)  (actual time=0.00129..0.00136 rows=0.734 loops=100000)
            -> Materialize with deduplication  (actual time=82.1..82.1 rows=73381 loops=1)
                -> Filter: (o.member_id is not null)  (actual time=0.98..41.6 rows=200000 loops=1)
                    -> Covering index scan on o using idx_orders_member  (actual time=0.979..26.4 rows=200000 loops=1)

サブクエリの結果を 重複除去した一時表(Materialize with deduplication) にして、会員ごとに1行検索する準結合です。NOT EXISTS は同じ形の Nested loop antijoin、LEFT JOIN IS NULL も Nested loop antijoin になりました。

NOT IN は antijoin にはならず、Filter: <in_optimizer>(m.id,m.id in (select #2) is false) というフィルターの下で、重複除去した一時表を1行ずつ引く計画になりました。サブクエリに WHERE o.member_id IS NOT NULL を足しても、この環境では同じ計画(208ms)でした。MySQL リファレンスマニュアルには「比較する式が NULL を取りうる場合、antijoin 変換はほとんどのケースで適用できない」とあり、orders.member_id を NULL 許容で定義していることが理由と考えられます。

MySQL は 8.0.16 以降で EXISTS を準結合に、8.0.17 以降で NOT EXISTS を antijoin に変換できるようになっています(MySQL 8.0 リファレンスマニュアル「Optimizing IN and EXISTS Subquery Predicates with Semijoin and Antijoin Transformations」)。「EXISTS の方が速い」「IN は遅い」という説明は、この変換が入る前のバージョンを前提にしたもの です。現在のバージョンでは実行計画で確かめるのが確実です。

MySQLで覚えておきたい制限

IN のサブクエリに LIMIT を書くと、MySQL 8.0 ではエラーになります。

SELECT id FROM d_members
WHERE id IN (SELECT member_id FROM d_orders ORDER BY amount DESC LIMIT 2);
ERROR 1235 (42000) at line 1: This version of MySQL doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery'

派生テーブル(FROM句のサブクエリ)で1段包む と動きます。

SELECT id FROM d_members
WHERE id IN (SELECT member_id FROM (
  SELECT member_id FROM d_orders ORDER BY amount DESC LIMIT 2
) t);
id
1
2

PostgreSQL は LIMIT 付きサブクエリを IN の中にそのまま書けます。

ANY / ALL の書き方

IN は = ANY と同じ意味、NOT IN は <> ALL と同じ意味です。ANY / ALL を使うと「〜のどれかより大きい」「〜のすべてより大きい」が書けます。

-- 会員1の注文(1200, 800)のどれよりも大きい注文
SELECT id, amount FROM d_orders
WHERE amount > ALL (SELECT amount FROM d_orders WHERE member_id = 1) ORDER BY id;

-- 会員1の注文のどれか1つより大きい注文
SELECT id, amount FROM d_orders
WHERE amount > ANY (SELECT amount FROM d_orders WHERE member_id = 1) ORDER BY id;

実行結果(PostgreSQL 16):

 id  | amount 
-----+--------
 103 |   3000
(1 row)

 id  | amount 
-----+--------
 101 |   1200
 103 |   3000
(2 rows)

ALL も NOT IN と同じく NULL の影響を受け、サブクエリが0件のときは常に TRUE になります。

SELECT 5000 > ALL (SELECT amount FROM d_orders) AS no_null,
       5000 > ALL (VALUES (1200),(NULL))       AS with_null,
       5000 > ALL (SELECT 1 WHERE false)       AS empty;
 no_null | with_null | empty 
---------+-----------+-------
 t       |           | t
(1 row)
書き方 同じ意味 NULLが混ざったとき 0件のとき
x IN (…) x = ANY (…) 一致があればTRUE、なければNULL FALSE
x NOT IN (…) x <> ALL (…) NULL(行が残らない) TRUE
x > ANY (…) ― 条件を満たす値があればTRUE FALSE
x > ALL (…) x > (SELECT max(…)) に近い NULL(行が残らない) TRUE

x > ALL (…) を x > (SELECT max(…) …) に書き換えると、0件のときは max が NULL になり結果が変わる点に注意してください。

相関サブクエリの書き方

相関サブクエリは EXISTS 以外に、SELECT句で「1行ごとの関連値」を取る ときにも使います。

-- 会員ごとの最新注文の金額(注文がなければ NULL)
SELECT m.id, m.name,
       (SELECT o.amount FROM d_orders o
         WHERE o.member_id = m.id
         ORDER BY o.id DESC LIMIT 1) AS last_amount
FROM d_members m
ORDER BY m.id;

実行結果(PostgreSQL 16):

 id | name | last_amount 
----+------+-------------
  1 | 佐藤 |         800
  2 | 鈴木 |        3000
  3 | 高橋 |            
  4 | 田中 |            
(4 rows)

書くときのポイントは3つです。

ポイント 理由
内側のテーブルに別名を付け、外側の列は m.id のように別名付きで書く 同名の列があると、内側の列として解釈されて意図しない条件になる
SELECT句の相関サブクエリは LIMIT 1 や集計関数で1行にする 2行以上で「more than one row」エラーになる
結合キー(orders.member_id)にインデックスを用意する 外側1行ごとの検索がインデックスで済む

別名を付け忘れた例として、WHERE EXISTS (SELECT 1 FROM d_orders WHERE member_id = id) と書くと、id は内側の d_orders.id と解釈され、外側の会員とは無関係な条件になります。相関サブクエリでは外側・内側の両方に別名を付ける と読み違いを防げます。

迷ったときの判断表

やりたいこと おすすめ 理由
固定の値リストで絞る IN ('A', 'B', 'C') 最も読みやすい
子テーブルに1件でもある行を取る EXISTS または IN PG16・MySQL8.0とも同じ計画。読みやすい方でよい
子テーブルに1件もない行を取る NOT EXISTS NULLの影響がなく、anti join になる
NOT IN を使いたい 列を NOT NULL にするか IS NOT NULL を付ける 0件になるのを防ぐ
子テーブルの列も一緒に表示する JOIN(必要なら集計) IN / EXISTS では子の列を取れない
複数列の組で存在チェック EXISTS(条件をANDで並べる) PGは (a,b) IN (…) も可だが、EXISTS の方が DB を問わず書ける
1行ごとの関連値(最新・合計)を出す SELECT句の相関サブクエリ、または LATERAL / ウィンドウ関数 件数が多いときは実行計画を見て選ぶ
「〜すべてより大きい」 > ALL 0件とNULLの扱いを確認する
遅いとき まず結合キーのインデックスと EXPLAIN IN / EXISTS の書き換えより効果が大きい

よくあるつまずきチェックリスト

チェック 起きること 対処
NOT IN のサブクエリ列が NULL を取りうる 結果が0件 NOT EXISTS にする
JOIN で存在チェックしている 行が重複する EXISTS にするか DISTINCT
スカラーサブクエリが2行以上返す more than one row / ERROR 1242 LIMIT 1 か集計関数
MySQL で IN のサブクエリに LIMIT ERROR 1235 派生テーブルで包む
相関サブクエリで別名がない 条件が内側だけで閉じる 外側・内側に別名
EXISTS を OR でつないでいる 準結合にならず SubPlan UNION に分ける等を検討

よくある質問

Q. EXISTSとINはどちらが速いですか? A. PostgreSQL 16 と MySQL 8.0 で10万件のデータを試した範囲では、肯定形の IN と EXISTS は同じ実行計画になり、速度差はありませんでした。 速度を左右するのは、サブクエリ側の結合キーのインデックスと、否定形で NOT IN を使っているかどうかです。

Q. NOT IN は使わない方がよいですか? A. サブクエリの列が NOT NULL 制約付きで、結果が小さいとわかっているなら NOT IN でも正しく動きます。ただし NULL が入ると0件になること、PostgreSQL ではハッシュがメモリに収まらないと大きく遅くなることから、否定の存在チェックは NOT EXISTS を標準にしておく と安心です。

Q. EXISTS のサブクエリは SELECT 1 と SELECT * のどちらがよいですか? A. EXISTS は行があるかどうかだけを見るため、SELECT句の中身は結果にも性能にも影響しません。意図が伝わる SELECT 1 がおすすめ です。

Q. 相関サブクエリは遅いと聞きました。 A. 論理的には外側の1行ごとに評価しますが、PostgreSQL も MySQL も多くの場合は結合に書き換えます。今回の測定でも、外側1,000件・インデックスありの相関EXISTSは約1.2msでした。遅いときは書き方より先に EXPLAIN で計画とインデックスを確認 してください。

まとめ

  • IN は「値がリストに含まれるか」、EXISTS は「サブクエリが1行でも返すか」 で判定する。
  • 肯定形の IN と EXISTS は、PG16・MySQL 8.0 とも同じ実行計画 になり、10万件でも速度差は誤差の範囲だった。
  • NOT IN はサブクエリに NULL が1つでもあると0件 になる。NOT EXISTS、IS NOT NULL、LEFT JOIN(主キーで IS NULL)で直せる。
  • PostgreSQL の NOT IN は anti join にならず、メモリ不足時は 2ms 対 33秒 という差になった。
  • JOIN は子の件数ぶん行が増える。存在チェックなら IN / EXISTS、子の列が要るなら JOIN。
  • ANY / ALL は IN / NOT IN の一般形。ALL は NULL と0件の扱いに注意。
  • 遅いときは書き方を変える前に、結合キーのインデックスと EXPLAIN を確認する。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「SQLのEXISTSとINの違い|NULLの罠と実行計画で見る使い分け」

株式会社RJCでは、SQLのサブクエリと実行計画の読み方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?