Apprenez en lisant dans l'ordre

Réécrire IN, EXISTS et les sous-requêtes corrélées

Comment IN et EXISTS diffèrent dans le plan, comment réécrire une sous-requête corrélée en JOIN + agrégation, et comment NOT IN renvoie silencieusement 0 ligne avec NULL — le tout vérifié dans le plan de requête.

Les données utilisées dans cet article — perf_sales et employee

Deux requêtes qui renvoient le même résultat peuvent quand même produire des plans de requête différents (les étapes que la base de données a choisies pour décider quelles tables lire dans quel ordre, et quels index utiliser).

Dans cet article tu apprendras quand utiliser IN par rapport à EXISTS, comment éliminer une sous-requête corrélée (une sous-requête ré-évaluée pour chaque ligne externe), et comment réécrire « ne garder que les lignes qui ont (ou n'ont pas) une correspondance » — en comparant les plans de requête avec EXPLAIN QUERY PLAN au fur et à mesure.

Avant de te lancer dans les exercices, vérifions les définitions de colonnes et les données d'exemple pour les deux tables utilisées dans cet article — perf_sales et employee.

① Utilise PRAGMA table_info(perf_sales); et PRAGMA table_info(employee); pour confirmer les définitions de colonnes des deux tables.

② Utilise SELECT * FROM perf_sales LIMIT 5; et SELECT * FROM employee LIMIT 5; pour prévisualiser les 5 premières lignes de chacune. perf_sales a 50 000 lignes, donc ajoute toujours LIMIT lors d'une prévisualisation.

Éditeur SQL

Exécutez une requête pour voir les résultats

IN et EXISTS — deux façons d'écrire la même vérification

Il y a deux façons de demander « y a-t-il une ligne correspondante dans une autre table ? ».

WHERE col IN (SELECT ...) vérifie si la valeur se trouve dans l'ensemble renvoyé par la requête interne, alors que WHERE EXISTS (SELECT 1 FROM ... WHERE ...) vérifie si la requête interne renvoie au moins une ligne.

Dans de nombreux cas les deux produisent le même résultat, et la base de données peut traiter l'une ou l'autre comme « ne garder que les lignes qui ont une correspondance ».

Préfixer la requête avec EXPLAIN QUERY PLAN requête; affiche les étapes que la base de données a choisies, ligne par ligne.

SCAN perf_sales signifie un parcours complet de lignes, SEARCH ... USING INDEX signifie aller directement aux lignes nécessaires via un index, et CORRELATED SCALAR SUBQUERY est une sous-requête qui est ré-évaluée pour chaque ligne externe.

IN et EXISTS — convergeant vers le même plan
WHERE emp_id IN (SELECT emp_id FROM employee WHERE dept_id=1)WHERE EXISTS (SELECT 1 FROM employee e WHERE e.emp_id=p.emp_id AND e.dept_id=1)Garder les lignes dontla valeur est dans l'ensembleGarder les lignes oùl'interne renvoie une ligneConvergent souventvers le même planMême résultatvérifier le plan avec EXPLAIN
IN demande si une valeur est dans un ensemble ; EXISTS demande si la requête interne renvoie au moins une ligne. La plupart des bases de données réduisent les deux au même plan « ne garder que les lignes qui ont une correspondance ».
-- Exemple : compter les ventes traitées par les employés du dept_id=2, de deux façons

-- Forme IN
SELECT COUNT(*) FROM perf_sales
WHERE emp_id IN (SELECT emp_id FROM employee WHERE dept_id = 2);

-- Forme EXISTS (corrélée)
SELECT COUNT(*) FROM perf_sales p
WHERE EXISTS (
  SELECT 1 FROM employee e
  WHERE e.emp_id = p.emp_id AND e.dept_id = 2
);

-- Préfixe avec EXPLAIN QUERY PLAN pour voir le plan
-- EXPLAIN QUERY PLAN SELECT COUNT(*) FROM perf_sales WHERE ...;

Soyons plus concrets. Comment IN et EXISTS se comportent-ils selon la taille de la table externe et la taille de la sous-requête interne et l'existence d'un index ?

Les deux cas ci-dessous — et quelle forme est la plus rapide dans chacun — sont résumés dans le schéma qui suit.

-- Cas A : externe perf_sales (50 000 lignes), interne employés dept_id=1 (~5 lignes, petit et fixe)

-- Forme IN
SELECT * FROM perf_sales
WHERE emp_id IN (SELECT emp_id FROM employee WHERE dept_id = 1);

-- Forme EXISTS
SELECT * FROM perf_sales p
WHERE EXISTS (
  SELECT 1 FROM employee e
  WHERE e.emp_id = p.emp_id AND e.dept_id = 1
);
Cas A — l'interne est petit et fixe (IN tend à gagner)
Cas Aexterne perf_sales (50 000)interne dept_id=1 (~5)Évaluer l'interne une fois,réutiliser la liste de valeursTend à revérifier l'internepour chacune des 50 000 lignesIN tend à gagnerpetit internemise en cache facileINEXISTSplus rapide
Quand l'ensemble interne (employés dept_id=1) est petit et fixe, IN peut évaluer l'interne une fois et réutiliser la liste de valeurs — généralement plus rapide qu'EXISTS. La flèche verte pointe vers le côté le plus rapide.
-- Cas B : externe employee (30 lignes), interne perf_sales (50 000 lignes, avec un index sur emp_id)

-- Forme IN
SELECT * FROM employee e
WHERE e.emp_id IN (SELECT emp_id FROM perf_sales);

-- Forme EXISTS
SELECT * FROM employee e
WHERE EXISTS (
  SELECT 1 FROM perf_sales p
  WHERE p.emp_id = e.emp_id
);
Cas B — externe petit avec un index sur l'interne (EXISTS tend à gagner)
Cas Bexterne employee (30)interne perf_sales (50k + index)Construire une liste de valeursde 50 000 est lourd30 sondages d'index, chacuns'arrête à la première corresp.EXISTS tend à gagnerpetit externe + indexl'arrêt anticipé paieINEXISTSplus rapide
Quand l'externe (30 employés) est petit et que l'interne (perf_sales) a un index sur la clé de jointure, EXISTS peut sonder l'index pour chaque ligne externe et s'arrêter dès qu'il en trouve une (arrêt anticipé) — généralement plus rapide qu'IN. La flèche verte pointe vers le côté le plus rapide.

Conseil — les bases modernes ne montrent presque aucune vraie différence de vitesse

Nous avons trié les choses en « IN tend à gagner / EXISTS tend à gagner », mais c'est une tendance enracinée dans des optimiseurs plus anciens.

Dans les bases modernes comme SQLite, PostgreSQL et MySQL 8.0+, à la fois IN (SELECT ...) et un EXISTS corrélé se font réécrire vers le même plan « ne garder que les lignes qui ont une correspondance », et le plan réel et la vitesse en pratique finissent à peu près identiques.

Autrement dit, tu peux choisir celle qui se lit mieux.

Quand la différence de plan ou de vitesse importe vraiment, la règle est de mesurerEXPLAIN QUERY PLAN et temps d'horloge.

Écris « le nombre de perf_sales traités par les employés avec dept_id 2 » sous deux formes — IN et EXISTS — et compare chaque plan de requête. (Exécute-le correctement pour révéler l'explication.)

① Préfixe avec EXPLAIN QUERY PLAN pour afficher le plan de la requête de comptage sous la forme emp_id IN (SELECT ...).

② Puis affiche le plan pour le même comptage écrit sous forme EXISTS (en corrélant contre employee) — encore avec EXPLAIN QUERY PLAN.

③ Compare les deux plans, en regardant les mots SCAN / SEARCH / USING INDEX et ainsi de suite.

Éditeur SQL

Exécutez une requête pour voir les résultats

Réécrire une sous-requête corrélée en JOIN + agrégation

Une sous-requête corrélée dans la liste SELECT (une ré-évaluée par ligne externe) se fait appeler de plus en plus souvent à mesure que le nombre de lignes grandit.

Dans EXPLAIN QUERY PLAN elle apparaît comme une ligne CORRELATED SCALAR SUBQUERY.

Dans de nombreux cas elle peut être réécrite comme une seule agrégation préparée à l'avance et jointe — la ré-évaluation par ligne disparaît.

Le motif de réécriture transforme « pour chaque ligne externe, appeler (SELECT ... WHERE child.key = parent.key) » en « construire la table d'agrégation une fois avec GROUP BY key, puis JOIN à la parente ».

Le résultat est le même et la corrélation disparaît du plan.

-- Avant : total des ventes par employé via une sous-requête corrélée
SELECT e.emp_id, e.name,
  (SELECT SUM(p.amount) FROM perf_sales p
   WHERE p.emp_id = e.emp_id) AS total
FROM employee e
ORDER BY e.emp_id;

-- Après : construire l'agrégation une fois et JOIN
WITH sales_sum AS (
  SELECT emp_id, SUM(amount) AS total
  FROM perf_sales
  GROUP BY emp_id
)
SELECT e.emp_id, e.name, s.total
FROM employee e
LEFT JOIN sales_sum s ON s.emp_id = e.emp_id
ORDER BY e.emp_id;
Sous-requête corrélée → une agrégation + JOIN
AvantAprèsPour chaque employéappeler (SELECT SUM(amount) ...)encore — forme corréléeGROUP BY emp_idsur perf_sales une foispour construire l'agrégation→ LEFT JOIN à employeeSous-requête ré-évaluéepar ligneUne agrégationcorrélation disparue du plan
Appeler une sous-requête pour chaque ligne externe peut être réécrit en agrégeant une fois sur la clé puis en joignant. La ré-évaluation disparaît et la corrélation tombe du plan.

Exécute « une liste du total de perf_sales de chaque employé » de deux façons — comme sous-requête corrélée et comme réécriture — et regarde comment le plan change.

① Préfixe avec EXPLAIN QUERY PLAN pour afficher le plan de la version qui met (SELECT SUM(amount) FROM perf_sales WHERE même emp_id) dans la liste SELECT comme sous-requête corrélée.

② Roule la même agrégation dans un CTE qui fait GROUP BY sur emp_id une fois, LEFT JOIN-le à employee, et affiche ce plan aussi avec EXPLAIN QUERY PLAN.

③ Confirme que la ligne CORRELATED SCALAR SUBQUERY de la version corrélée disparaît dans la version réécrite.

Éditeur SQL

Exécutez une requête pour voir les résultats

Le piège NULL de NOT IN — évite-le avec NOT EXISTS

Quand tu demandes « aucune ligne correspondante » — une anti-jointure — NOT IN (SELECT ...) a un piège.

Si même un seul NULL se glisse dans l'ensemble interne, la logique à trois valeurs (TRUE / FALSE / UNKNOWN) rend la condition entière UNKNOWN, et le résultat revient avec 0 ligne.

En revanche, NOT EXISTS (SELECT 1 ... WHERE ...) demande seulement si l'interne produit une ligne, donc NULL ne le casse pas et l'anti-jointure fonctionne correctement.

Quand tu écris une anti-jointure, utilise NOT EXISTS — ou, si tu dois utiliser NOT IN, ajoute WHERE col IS NOT NULL à l'intérieur pour éliminer les NULL.

Ce cours utilise NOT EXISTS comme forme standard.

Pourquoi NOT IN avec un NULL renvoie 0 ligne
emp_id NOT IN (3, 7, NULL)Logique à trois valeursÉvaluation pour emp_id = 5→ NOT (5=3 OR 5=7 OR 5=NULL)= NOT (FALSE OR FALSE OR UNKNOWN)= NOT (UNKNOWN) = UNKNOWN→ la ligne ne survit pasTant que l'ensemble internea un seul NULL, chaque lignemélange UNKNOWN dans son résultatLe résultat est toujours 0 ligne
NOT IN est évalué en interne comme NOT (col = val1 OR col = val2 ...). Une comparaison contre NULL est UNKNOWN sous la logique à trois valeurs, et cela se propage vers l'extérieur — aucune ligne ne survit.
Le piège NULL de NOT IN et la réécriture vers NOT EXISTS
NOT IN (SELECT mgr_emp FROM team)-- mgr_emp a NULLNOT EXISTS (SELECT 1 FROM team t WHERE t.mgr_emp = p.emp_id)NULL rend toutUNKNOWN → 0 ligneDemande seulement l'existence→ NULL n'importe pasUtilise NOT EXISTSpour les anti-jointuresOu ajoute IS NOT NULLà l'intérieur de NOT IN
Si même un seul NULL est dans l'ensemble interne, NOT IN s'effondre en UNKNOWN globalement et renvoie 0 ligne. NOT EXISTS demande seulement si une ligne existe, donc NULL ne l'affecte pas et l'anti-jointure fonctionne correctement.
-- Construis un exemple avec NULL dans l'ensemble interne, en utilisant team
-- team(mgr_emp) inclut délibérément un NULL
WITH team(mgr_emp) AS (
  VALUES (3), (7), (NULL)
)
-- Piège : avec NULL présent, NOT IN renvoie 0 ligne
SELECT COUNT(*) FROM perf_sales
WHERE emp_id NOT IN (SELECT mgr_emp FROM team);

-- Correction : NOT EXISTS n'est pas affecté par NULL, donc l'anti-jointure fonctionne
WITH team(mgr_emp) AS (
  VALUES (3), (7), (NULL)
)
SELECT COUNT(*) FROM perf_sales p
WHERE NOT EXISTS (
  SELECT 1 FROM team t WHERE t.mgr_emp = p.emp_id
);

Observe — via les comptes de lignes — ce qui se passe quand l'ensemble interne contient un NULL, en comparant NOT IN et NOT EXISTS. C'est le dernier exercice de l'article. Construis une petite liste de valeurs appelée team (trois valeurs : 3, 7, NULL) comme CTE, puis compte « les lignes perf_sales dont emp_id n'est pas dans cette liste » de deux façons.

① Construis un CTE avec 3, 7, NULL en utilisant WITH team(mgr_emp) AS (VALUES ...), et compte les lignes avec emp_id NOT IN (SELECT mgr_emp FROM team).

② Écris la même vérification comme NOT EXISTS (SELECT 1 FROM team t WHERE t.mgr_emp = p.emp_id) et compte les lignes.

③ Compare les deux comptes et confirme que la version NOT IN est 0.

Éditeur SQL

Exécutez une requête pour voir les résultats
QUIZ

Vérification des connaissances

Répondez à chaque question une par une.

Question 1Laquelle des affirmations suivantes décrit le mieux la relation entre WHERE col IN (SELECT ...) et WHERE EXISTS (SELECT 1 ... WHERE ...) ?

Question 2Quelle est la réécriture typique pour une sous-requête scalaire corrélée dans la liste SELECT (une qui appelle (SELECT SUM(...) WHERE child = parent) par ligne) ?

Question 3Quand l'ensemble interne pourrait contenir NULL, quelle est la façon sûre d'écrire une anti-jointure (lignes qui ne correspondent pas) ?