Comment utiliser les jointures SQL avancées sans erreur ?

J’utilise les jointures SQL avancées quand INNER JOIN et LEFT JOIN deviennent lourds, ambigus ou trop verbeux. LATERAL, semi join et anti join règlent trois vrais problèmes SQL : produire plusieurs lignes par ligne source, filtrer sur une existence, ou trouver ce qui manque.

Pourquoi dépasser INNER et LEFT JOIN ?

Les jointures classiques sont indispensables. Je les utilise tous les jours. Mais dès qu’on sort du cas simple “je colle deux tables sur une clé”, INNER JOIN et LEFT JOIN ne sont pas toujours les meilleurs outils.

Le problème arrive souvent quand la logique devient plus fine. On ne veut plus seulement ramener des colonnes. On veut appliquer une logique ligne par ligne, vérifier qu’une correspondance existe, ou au contraire vérifier qu’elle n’existe pas.

J’ai souvent vu des requêtes analytiques devenir illisibles juste parce qu’on essayait de tout résoudre avec les deux mêmes jointures. Ce n’était pas “faux” au départ, mais ça finissait avec des doublons, des DISTINCT ajoutés pour réparer, des sous-requêtes empilées, et un plan SQL moins propre.

Comme on dit à Brive, un bon plan de marquage vaut mieux qu’un bon reporting ! Si besoin, consultez moi - faites appel à un super consultant en tracking client et server side.

Il y a trois cas typiques où je préfère sortir des jointures classiques :

  • Une fonction ou une sous-requête renvoie plusieurs lignes pour chaque ligne d’entrée. Dans ce cas, LATERAL est souvent plus clair. Une jointure LATERAL permet à la sous-requête de dépendre de la ligne courante de la table principale.
  • On cherche les lignes qui ont au moins une correspondance. Là, un semi join est plus naturel. Le but n’est pas de ramener toutes les lignes liées, juste de savoir si au moins une existe.
  • On cherche les lignes qui n’ont aucune correspondance. C’est le rôle de l’anti join. On filtre ce qui n’existe pas, sans bricoler une jointure externe et un test sur NULL quand ce n’est pas nécessaire.

Forcer un INNER JOIN dans ces cas-là peut multiplier les lignes sans qu’on s’en rende compte. Forcer un LEFT JOIN peut rendre l’intention floue. Et quand l’intention est floue, la requête devient plus dure à maintenir, mais aussi plus dure à optimiser pour le moteur SQL.

Problème Technique adaptée Résultat attendu
Une logique doit s’exécuter ligne par ligne et peut renvoyer plusieurs résultats. LATERAL Une requête plus expressive, avec une dépendance claire à la ligne courante.
On veut garder les lignes qui ont au moins une correspondance. Semi join Un filtrage propre sans duplication inutile des lignes.
On veut garder les lignes qui n’ont aucune correspondance. Anti join Une exclusion lisible et souvent plus efficace.

Quand utiliser LATERAL ?

J’utilise LATERAL quand une sous-requête dans le FROM doit lire les colonnes de la ligne courante d’une table placée avant elle. C’est le détail qui change tout. Une sous-requête classique dans FROM vit un peu dans sa bulle, elle est évaluée indépendamment. Avec LATERAL, elle peut dire “donne-moi la valeur de cette ligne, et je vais produire quelque chose à partir d’elle”.

C’est très PostgreSQL comme réflexe, et franchement, dès qu’on manipule du texte, du JSON ou des tableaux, ça devient vite indispensable.

Les cas où je l’utilise le plus souvent sont assez nets :

  • Unnest sur des tableaux, pour transformer un tableau en plusieurs lignes, une ligne par élément.
  • Regexp_matches avec l’option globale g, pour obtenir une ligne par correspondance trouvée dans un texte.
  • Extraction ou découpage JSON, quand un champ JSON contient une liste d’objets à exploiter ligne par ligne.
  • Top N par groupe, par exemple récupérer les 3 dernières commandes de chaque client sans bricoler une grosse requête illisible.

Voici un exemple concret. Je pars d’une table google_file_store avec une colonne contents, et je veux compter les occurrences de bull et bear dans chaque contenu : SELECT g.id, COALESCE(bull.cnt, 0) AS bull_count, COALESCE(bear.cnt, 0) AS bear_count FROM google_file_store g LEFT JOIN LATERAL (SELECT COUNT(*) AS cnt FROM regexp_matches(LOWER(g.contents), ‘\\m(bull)\\M’, ‘g’)) bull ON TRUE LEFT JOIN LATERAL (SELECT COUNT(*) AS cnt FROM regexp_matches(LOWER(g.contents), ‘\\m(bear)\\M’, ‘g’)) bear ON TRUE;

Le point important, c’est que regexp_matches avec l’option g retourne plusieurs lignes, une par match. Sans LATERAL, la fonction ne peut pas simplement aller lire g.contents pour chaque ligne de google_file_store depuis le FROM. Avec LATERAL, PostgreSQL exécute cette petite logique pour chaque ligne source. On n’a pas une évaluation globale. On a bien une expansion ligne par ligne, puis ici un comptage.

Il faut juste garder un œil sur le volume. LATERAL est puissant, mais une ligne d’entrée peut devenir 10, 100 ou 10 000 lignes en sortie. Sur une grosse table, je valide toujours avec EXPLAIN ANALYZE. J’ai déjà vu une requête très propre en apparence exploser juste parce qu’un champ JSON contenait beaucoup plus d’éléments que prévu.

À retenir simplement : LATERAL sert à produire ou enrichir des lignes à partir de chaque ligne source. La semi join, elle, sert surtout à filtrer sans dupliquer. C’est le sujet juste après.

Quand choisir une semi join ?

Je choisis une semi join quand ma vraie question est simple : Est-ce qu’il existe au moins une ligne correspondante ? Pas quand je veux ramener des colonnes de la table de droite, ni quand je veux analyser chaque correspondance.

La différence avec un INNER JOIN est très concrète. Avec un INNER JOIN, si un client a cinq commandes correspondantes, le client peut ressortir cinq fois. C’est normal, SQL combine les lignes qui matchent. Avec une semi join, le client ressort une seule fois, parce qu’on ne demande pas les commandes, on demande juste si au moins une commande existe.

Les deux écritures les plus courantes sont celles-ci : WHERE EXISTS et WHERE IN. EXISTS est souvent plus clair et plus général, surtout quand la condition dépend de plusieurs colonnes, ou quand la sous-requête est corrélée, c’est-à-dire qu’elle utilise une valeur de la requête principale.

SELECT
    c.customer_id,
    c.name
FROM customers c
WHERE EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.customer_id = c.customer_id
      AND o.amount > 100
);

Ici, je sélectionne les clients qui ont au moins une commande de plus de 100 dollars. Même si un client a dix commandes au-dessus de 100, il sort une seule fois. Avec un INNER JOIN, il faudrait penser à ajouter un DISTINCT si je ne veux pas de doublons, et c’est souvent là que les erreurs commencent.

On peut aussi écrire certains cas avec IN :

SELECT
    c.customer_id,
    c.name
FROM customers c
WHERE c.customer_id IN (
    SELECT o.customer_id
    FROM orders o
    WHERE o.amount > 100
);

Cette version marche bien quand on compare une seule colonne. Dès que ça devient plus riche, par exemple plusieurs colonnes ou une condition métier un peu précise, je préfère EXISTS. Les optimiseurs SQL modernes savent souvent transformer ces conditions en vrai plan de semi join, donc je ne l’écris pas seulement pour la performance, je l’écris aussi pour l’intention.

Dans les projets analytics, j’utilise souvent EXISTS pour ça. Quand je relis la requête trois mois plus tard, je comprends tout de suite qu’on filtre sur une existence, pas qu’on veut enrichir la ligne avec des données de droite.

Approche Duplication possible Lisibilité de l’intention Cas multi-colonnes
INNER JOIN Oui, si plusieurs lignes matchent à droite Moyenne, car on peut croire qu’on veut enrichir Très bon
WHERE EXISTS Non, on garde seulement la ligne de gauche Très claire, on teste une existence Très bon
WHERE IN Non, dans l’usage classique Claire pour une colonne simple Moins pratique

Quand préférer une anti join ?

Je préfère une anti join quand je veux trouver les lignes de gauche pour lesquelles aucune ligne correspondante n’existe à droite. C’est vraiment l’inverse de la semi join. La semi join répond à une question simple : “Est-ce qu’il existe au moins une correspondance ?”. L’anti join répond : “Est-ce qu’il n’en existe aucune ?”.

Les deux écritures classiques sont assez connues. La première utilise une jointure externe : LEFT JOIN … WHERE colonne_droite IS NULL. La seconde utilise une condition d’absence : WHERE NOT EXISTS. En PostgreSQL moderne, j’ai tendance à préférer NOT EXISTS. C’est souvent plus lisible, et l’optimiseur produit généralement de bons plans d’exécution. Le LEFT JOIN IS NULL reste utile, surtout quand la requête est déjà construite autour d’une jointure externe et qu’on veut garder cette forme.

Exemple typique : je veux lister les utilisateurs gratuits qui n’ont effectué aucun appel en avril 2020. Le filtre sur les dates doit rester dans la sous-requête, sinon on casse la logique d’absence.

SELECT u.id, u.email
FROM users u
WHERE u.plan = 'free'
  AND NOT EXISTS (
    SELECT 1
    FROM calls c
    WHERE c.user_id = u.id
      AND c.created_at >= DATE '2020-04-01'
      AND c.created_at < DATE '2020-05-01'
  );

Le détail important, c’est que la sous-requête ne cherche pas à ramener des données. Le SELECT 1 veut juste dire : “Je teste l’existence d’au moins une ligne”. S’il n’y en a aucune, l’utilisateur est gardé. J’ai vu pas mal d’erreurs chez des clients à cause d’un filtre de date mis au mauvais endroit, et là, la requête ne répond plus du tout à la même question.

Je me méfie aussi de NOT IN. Si la sous-requête peut contenir des valeurs NULL, le résultat peut devenir surprenant, voire vide. Quand j’exprime une absence, mon choix par défaut reste NOT EXISTS.

Écriture Usage Point fort Piège à surveiller
NOT EXISTS Exprimer qu’aucune ligne liée n’existe Lisible, robuste, souvent bien optimisé Mettre les filtres dans la sous-requête
LEFT JOIN IS NULL Faire une anti join avec une jointure externe Pratique si la requête utilise déjà des jointures Tester une colonne droite vraiment non nullable
NOT IN Exclure une liste de valeurs Simple sur une petite liste connue Dangereux si la sous-requête contient des NULL

Alors quelle jointure SQL choisir maintenant ?

Je résume simplement. LATERAL sert quand une requête ou une fonction doit travailler ligne par ligne et produire plusieurs lignes à partir d’une ligne source. La semi join sert à garder les lignes qui ont au moins une correspondance, sans créer de doublons inutiles. L’anti join sert à trouver ce qui n’a aucune correspondance, souvent avec NOT EXISTS pour garder une intention claire.

Le vrai gain, ce n’est pas de connaître des jointures rares pour faire malin. C’est d’écrire des requêtes plus justes, plus lisibles, et souvent plus faciles à optimiser. Vous gagnez du temps, et vos résultats SQL deviennent plus fiables.

FAQ

  • Qu’est-ce qu’une jointure LATERAL en SQL ?
    Une jointure LATERAL permet à une sous-requête placée dans le FROM d’utiliser les colonnes des tables écrites avant elle. Je m’en sers surtout quand une fonction retourne plusieurs lignes pour chaque ligne source, par exemple avec regexp_matches, unnest ou un traitement JSON.
  • Quelle différence entre INNER JOIN et semi join ?
    INNER JOIN combine les lignes des deux côtés et peut dupliquer les lignes de gauche si plusieurs correspondances existent à droite. Une semi join garde seulement les lignes de gauche qui ont au moins une correspondance. Elle répond à une question d’existence, pas à un besoin d’enrichissement.
  • Faut-il utiliser EXISTS ou IN pour une semi join ?
    J’utilise souvent EXISTS par défaut, parce que l’intention est claire et que ça marche bien avec des conditions corrélées ou multi-colonnes. IN reste très lisible pour un cas simple sur une seule colonne. Le choix dépend aussi du moteur SQL et du plan généré.
  • Quelle est la meilleure façon d’écrire une anti join ?
    Dans PostgreSQL moderne, NOT EXISTS est souvent le choix le plus clair pour exprimer une absence de correspondance. LEFT JOIN avec IS NULL reste courant et utile, mais il faut bien placer les conditions de filtre pour ne pas changer le sens de la requête.
  • Pourquoi éviter NOT IN pour chercher les lignes absentes ?
    NOT IN peut donner des résultats surprenants si la sous-requête contient des valeurs NULL. C’est un piège classique. Quand je veux exprimer aucune correspondance, je préfère NOT EXISTS, qui colle mieux à l’intention et évite ce problème dans la plupart des cas.

 

 

A propos de l’auteur

Je suis Franck Scandolera, expert et formateur en Tracking avancé server-side, Analytics Engineering, automatisation No/Low Code avec n8n, intégration de l’IA en entreprise et SEO/GEO. J’accompagne des équipes data, marketing et produit sur des sujets où la qualité des données, les requêtes SQL et les pipelines analytiques doivent tenir en production, pas juste marcher en démo.

Je dirige l’agence webAnalyste et l’organisme Formations Analytics. J’ai travaillé avec des références comme Logis Hôtel, Yelloh Village, BazarChic, la Fédération Française de Football ou Texdecor. Si vous voulez fiabiliser vos données, vos automatisations ou vos analyses, contactez-moi.

Retour en haut