> ## Content Index
> Fetch the complete content index at: https://www.idriss-benbassou.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# NOT IN vs NOT EXISTS en SQL : pourquoi le résultat diffère
- URL: https://www.idriss-benbassou.com/anti-joins-sql-not-in-not-exists-null/
- Published: 2026-05-15T13:23:39.000Z
- Updated: 2026-05-15T13:36:55.000Z
- Author: Idriss BENBASSOU
- Tags: SQL, #rating-2

"Trouver les X qui ne sont pas dans Y."

C'est une question qui revient en permanence quand tu fais de la data comme par exemple les clients qui n'ont pas passé de commande, les employés sans projet affecté, les produits jamais vendus, les utilisateurs sans login depuis 30 jours, les lignes orphelines après un `MERGE`.

Trois écritures SQL répondent à cette question. Elles paraissent équivalentes, et la plupart des tutos les présentent comme interchangeables. Sauf qu'elles ne le sont pas. Et la différence vient d'un seul concept que beaucoup oublient, la manière dont SQL gère les `NULL`.

Si tu as déjà eu une requête `NOT IN` qui renvoie zéro ligne sans raison apparente, ou un anti-join qui "marche" en dev et "ne marche plus" en prod après ajout d'une colonne nullable, c'est ce dont on va parler.

## Setup : un cas tout simple

Deux tables, format classique :

```sql
-- Table clients
| id | nom    |
|----|--------|
| 1  | Alice  |
| 2  | Bob    |
| 3  | Chloé  |
| 4  | David  |

-- Table commandes
| id  | client_id |
|-----|-----------|
| 101 | 1         |
| 102 | 2         |
| 103 | NULL      |   -- (client supprimé, import partiel...)

```

Objectif : trouver les clients qui n'ont jamais passé de commande. Réponse attendue : `Chloé` et `David`.

Cette colonne `client_id` nullable dans `commandes`, ça arrive tout le temps en vrai, soit parce que la donnée vient d'un import sale, soit parce que la modélisation autorise les valeurs manquantes. Et c'est elle qui va faire la différence.

## Approche 1 : NOT IN

L'écriture la plus intuitive :

```sql
SELECT *
FROM clients
WHERE id NOT IN (SELECT client_id FROM commandes);

```

Tu exécutes... et tu obtiens **zéro ligne**. Pas Chloé, pas David, rien.

Pourtant la requête a l'air correcte. Que se passe-t-il ?

> En SQL, `x NOT IN (1, 2, NULL)` est équivalent à `x <> 1 AND x <> 2 AND x <> NULL`. Or `x <> NULL` ne renvoie pas FALSE, il renvoie `UNKNOWN`. Et un `AND` qui contient un `UNKNOWN` ne peut jamais être `TRUE`. Donc aucune ligne ne sort.

Pour Chloé (id = 3), le moteur évalue :

```
3 NOT IN (1, 2, NULL)
= 3 <> 1 AND 3 <> 2 AND 3 <> NULL
= TRUE   AND TRUE    AND UNKNOWN
= UNKNOWN

```

Un `WHERE` ne garde que les lignes dont l'expression vaut `TRUE`. `UNKNOWN` est rejeté, exactement comme `FALSE`. Chloé est éliminée. Même chose pour David.

Résultat : un seul `NULL` dans la sous-requête fait disparaître l'intégralité du résultat. C'est un grand classique des bugs en prod qui n'apparaissent que des mois après la mise en place de la requête quand une ligne `NULL` arrive dans la table cible.

## Approche 2 : NOT EXISTS

Même question, écriture différente :

```sql
SELECT *
FROM clients c
WHERE NOT EXISTS (
  SELECT 1
  FROM commandes co
  WHERE co.client_id = c.id
);

```

Résultat : `Chloé` et `David`. Correct.

Pourquoi ça marche cette fois ? Parce que `NOT EXISTS` ne fait pas de comparaison globale avec une liste, il regarde simplement s'il existe au moins une ligne qui satisfait la condition. Pour Chloé, le moteur cherche une ligne dans `commandes` avec `client_id = 3`. La ligne avec `client_id = NULL` donne `NULL = 3 → UNKNOWN`, donc elle ne correspond pas. Aucune ligne ne correspond, donc `NOT EXISTS` vaut `TRUE`. Chloé sort dans le résultat.

Le `UNKNOWN` issu de la comparaison avec `NULL` est traité localement, ligne par ligne, dans la sous-requête. Il n'empoisonne pas le résultat global.

## Approche 3 : LEFT JOIN ... WHERE ... IS NULL

Le pattern "anti-join" historique, avant que `NOT EXISTS` ne soit bien optimisé partout :

```sql
SELECT c.*
FROM clients c
LEFT JOIN commandes co ON co.client_id = c.id
WHERE co.client_id IS NULL;

```

Résultat : `Chloé` et `David`. Correct aussi.

L'idée est différente : on joint tous les clients à leurs commandes, puis on ne garde que les clients qui n'ont aucun match (donc dont la colonne issue de la jointure est `NULL`). Là encore, le `NULL` dans `commandes.client_id` ne provoque pas de jointure parasite avec Chloé ou David.

## La logique de comparaison

> SQL ne fonctionne pas avec deux valeurs de vérité (TRUE et FALSE), mais avec trois : TRUE, FALSE et UNKNOWN.

Toutes les comparaisons impliquant un `NULL` renvoient `UNKNOWN`, pas `FALSE`. Et la différence entre les deux est énorme.

Quelques exemples :

| Expression            | Résultat                                      |
| --------------------- | --------------------------------------------- |
| NULL = NULL           | UNKNOWN                                       |
| NULL = 5              | UNKNOWN                                       |
| NULL <> 5             | UNKNOWN                                       |
| NULL IS NULL          | TRUE                                          |
| NULL IS NOT NULL      | FALSE                                         |
| 5 IN (1, 2, NULL)     | UNKNOWN (mais TRUE si 5 figure dans la liste) |
| 5 NOT IN (1, 2, NULL) | UNKNOWN                                       |

Et la table de vérité du `AND` :

| AND     | TRUE    | FALSE | UNKNOWN |
| ------- | ------- | ----- | ------- |
| TRUE    | TRUE    | FALSE | UNKNOWN |
| FALSE   | FALSE   | FALSE | FALSE   |
| UNKNOWN | UNKNOWN | FALSE | UNKNOWN |

Tu remarques que `FALSE AND UNKNOWN` \= `FALSE`, mais `TRUE AND UNKNOWN` \= `UNKNOWN`. C'est exactement ce qui se passe avec `NOT IN` : la première condition est `TRUE`, la deuxième aussi, la troisième est `UNKNOWN`, donc le tout devient `UNKNOWN`.

Une fois que tu as cette logique en tête, tu comprends pourquoi `IN` ne pose pas autant de problèmes que `NOT IN` :

- `5 IN (1, 5, NULL)` \=> la deuxième comparaison renvoie `TRUE`, et un `OR` avec un `TRUE` reste `TRUE`. La ligne sort.
- `5 NOT IN (1, 2, NULL)` \=> la dernière comparaison renvoie `UNKNOWN`, et un `AND` avec un `UNKNOWN` ne peut pas valoir `TRUE`. La ligne ne sort jamais.

C'est pour ça qu'on dit que `NOT IN` "se comporte mal" avec les `NULL`, et qu'`IN` n'a pas le même problème.

## La règle pratique

> Utilise `NOT EXISTS` par défaut. Garde `NOT IN` seulement si tu es sûr que ni la valeur à gauche ni les valeurs à droite ne peuvent être `NULL`, typiquement une liste codée en dur sans `NULL`. Les optimiseurs modernes savent souvent transformer `NOT EXISTS` ou `LEFT JOIN ... IS NULL` en anti-join physique, selon le moteur, les index et les statistiques. Mais ils doivent garder la sémantique de `NOT IN` donc si des `NULL` sont possibles, le piège reste là.

## Aller plus loin : pratiquer SQL

Les anti-joins, le comportement des `NULL`, les sous-requêtes corrélées, c'est le genre de sujet qui ne s'apprend qu'en écrivant des requêtes et en se prenant les pièges en face pas en tuto ou vidéos. Donc lire un article ne suffit pas, il faut écrire du SQL.

Sur DataCertification.fr, le module "Sous-requêtes et EXISTS" couvre exactement ces cas (anti-joins, corrélation, gestion des `NULL`) avec un éditeur SQL intégré au navigateur et une validation automatique. Tu écris la requête, tu la passes, et tu vois immédiatement si elle se comporte bien dans les cas piégeux.

👉 [Pratiquer ces patterns sur DataCertification.fr](https://datacertification.fr/apprendre-sql?ref=idriss-benbassou.com)

Si tu travailles déjà sur des cas plus complexes (modélisation, qualité de données, déduplication, SCD, incrémental), le [Lab Analytics Engineering](https://datacertification.fr/analytics-engineering?ref=idriss-benbassou.com) va plus loin et applique tout ça à des pipelines complets.

Tu veux que je t'accompagne sur un projet data (modélisation, performance SQL, Snowflake, dbt, gouvernance) ? 👉 [Réserver un appel de 30 minutes](https://calendly.com/idriss-benbassou-datavio/30min?ref=idriss-benbassou.com)

## Questions :

#### Pourquoi NOT IN renvoie zéro ligne en SQL ?

`NOT IN` renvoie zéro ligne dès qu'un seul `NULL` est présent dans la sous-requête ou la liste comparée. La raison vient de la logique SQL : `valeur <> NULL` ne renvoie pas `FALSE` mais `UNKNOWN`, et un `AND` qui contient un `UNKNOWN` ne peut jamais valoir `TRUE`. Aucune ligne ne passe le filtre. Pour éviter ce piège, utilise `NOT EXISTS` ou `LEFT JOIN ... IS NULL`.

#### NOT IN ou NOT EXISTS, lequel est plus rapide ?

Sur les moteurs modernes (Snowflake, PostgreSQL, BigQuery, SQL Server, Databricks), `NOT EXISTS` est en général aussi rapide ou plus rapide que `NOT IN`. L'optimiseur le transforme souvent en anti-join physique, alors que `NOT IN` doit gérer la logique des `NULL` en plus. La différence devient visible sur de grosses volumétries.

#### Pourquoi NULL = NULL ne renvoie pas TRUE en SQL ?

En SQL, `NULL` représente l'absence de valeur, pas une valeur connue qu'on pourrait comparer. La comparaison `NULL = NULL` renvoie `UNKNOWN` parce qu'on ne sait pas si deux valeurs manquantes représentent la même chose. Pour tester si une colonne est `NULL`, il faut utiliser `IS NULL` ou `IS NOT NULL`, qui sont les seules opérations qui renvoient un vrai booléen face à un `NULL`.

#### Comment faire un anti-join en SQL ?

Un anti-join trouve les lignes d'une table qui n'ont pas de correspondance dans une autre table. Plusieurs écritures SQL le permettent : `NOT IN`, `NOT EXISTS`, et `LEFT JOIN ... WHERE ... IS NULL`. La plus sûre est `NOT EXISTS`, parce qu'elle n'est pas piégée par les `NULL` et que les optimiseurs modernes la transforment en anti-join physique performant.

#### Peut-on remplacer NOT IN par NOT EXISTS sans risque ?

Oui, dans la grande majorité des cas. `NOT EXISTS` renvoie le résultat attendu (les lignes sans correspondance), là où `NOT IN` peut renvoyer un résultat vide si un `NULL` traîne.