Chapitre 823 minDébutant
Jointures SQL : INNER JOIN et LEFT JOIN
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- relier deux tables avec
INNER JOIN; - garder les lignes sans correspondance avec
LEFT JOIN; - joindre trois tables, ou une table avec elle-même ;
- passer d'une question en français à la bonne requête.
5 exercices corrigés2 schémas
Une jointure SQL combine les lignes de deux tables en suivant une clé étrangère : INNER JOIN garde les lignes qui ont une correspondance des deux côtés, LEFT JOIN garde aussi celles de la table de gauche qui n'en ont pas. Ce chapitre couvre aussi les jointures sur trois tables, l'auto-jointure et la méthode pour passer d'une question à une requête. Les exemples suivent le schéma de la base d'exemple parc.
Pourquoi joindre
La base range chaque information une seule fois : le nom d'un service est dans service, le nom d'un employé dans employe, et la clé étrangère employe.id_service relie les deux. Pour afficher « Julie Lefèvre, Comptabilité », tu dois donc recoller les deux tables en suivant cette clé. C'est une jointure. Toute jointure correcte suit une clé étrangère du schéma : si tu ne sais pas sur quelle colonne joindre, retourne au schéma de la base.
Le produit cartésien
Sans condition, combiner deux tables associe chaque ligne de la première à chaque ligne de la seconde :
SELECT COUNT(*) FROM service, employe;
| COUNT(*) |
|---|
| 84 |
6 services × 14 employés = 84 lignes, dont 70 n'ont aucun sens (Julie Lefèvre associée à la Direction, au Commercial, etc.). Ce produit cartésien s'écrit aussi service CROSS JOIN employe. Il a de rares usages volontaires, comme générer toutes les combinaisons possibles (chaque poste avec chaque logiciel obligatoire) pour ensuite chercher celles qui manquent.
INNER JOIN
La jointure interne garde les combinaisons qui vérifient la condition ON :
SELECT e.nom, e.prenom, s.libelle
FROM employe e
INNER JOIN service s ON s.id_service = e.id_service
ORDER BY s.libelle, e.nom;
| nom | prenom | libelle |
|---|---|---|
| Benali | Sofiane | Commercial |
| Petit | Thomas | Commercial |
| Roux | Camille | Commercial |
| Lefèvre | Julie | Comptabilité |
| Martin | Paul | Comptabilité |
| Morel | Karim | Comptabilité |
| Garnier | Hélène | Direction |
| Diallo | Moussa | Informatique |
| Lambert | Chloé | Informatique |
| Mercier | Antoine | Informatique |
| Bonnet | Inès | Logistique |
| Faure | Nadia | Logistique |
| Girard | Lucas | Logistique |
| Rousseau | Léa | Logistique |
INNER est facultatif : JOIN seul est une jointure interne. Les lettres e et s sont des alias de table : ils raccourcissent la requête et lèvent les ambiguïtés. Les deux tables ont une colonne id_service ; si tu écris SELECT id_service sans préfixe, MySQL ne sait pas laquelle choisir :
ERROR 1052 (23000): Column 'id_service' in field list is ambiguous
Le service Juridique n'apparaît pas : aucun employé ne le référence, donc aucune combinaison ne vérifie la condition. C'est la règle de la jointure interne : une ligne sans correspondance disparaît.
Jointure sur trois tables
Pour aller d'une table à une autre qui n'est pas directement reliée, tu traverses les tables intermédiaires en suivant les clés, même si tu n'affiches aucune de leurs colonnes. Qui utilise le logiciel Sage ? Le chemin dans le schéma : logiciel → installation → poste → employe.
SELECT e.nom, e.prenom
FROM logiciel l
JOIN installation i ON i.id_logiciel = l.id_logiciel
JOIN poste p ON p.id_poste = i.id_poste
JOIN employe e ON e.id_employe = p.id_employe
WHERE l.nom = 'Sage 100 Comptabilité'
ORDER BY e.nom;
| nom | prenom |
|---|---|
| Lefèvre | Julie |
| Morel | Karim |
Chaque JOIN ajoute une table et sa condition. Écris-les dans l'ordre du chemin : chaque condition relie la nouvelle table à une table déjà présente. Autre exemple, les tickets non résolus avec le nom du demandeur et son service :
SELECT t.id_ticket, t.titre,
CONCAT(e.prenom, ' ', e.nom) AS demandeur,
s.libelle AS service
FROM ticket t
JOIN employe e ON e.id_employe = t.id_demandeur
JOIN service s ON s.id_service = e.id_service
WHERE t.statut <> 'resolu'
ORDER BY t.id_ticket;
| id_ticket | titre | demandeur | service |
|---|---|---|---|
| 6 | Ventilateur bruyant | Lucas Girard | Logistique |
| 7 | Installer Sage pour Paul Martin | Paul Martin | Comptabilité |
| 11 | Accès VPN impossible | Sofiane Benali | Commercial |
| 12 | Clavier cassé | Léa Rousseau | Logistique |
| 13 | Lenteurs réseau à l'entrepôt | Nadia Faure | Logistique |
| 14 | Batterie à remplacer | Hélène Garnier | Direction |
LEFT JOIN et RIGHT JOIN
La jointure externe gauche garde toutes les lignes de la table de gauche (celle écrite avant LEFT JOIN). Quand une ligne n'a pas de correspondance, les colonnes de la table de droite valent NULL.
SELECT s.libelle, e.nom
FROM service s
LEFT JOIN employe e ON e.id_service = s.id_service
WHERE s.id_service IN (5, 6)
ORDER BY s.libelle, e.nom;
| libelle | nom |
|---|---|
| Informatique | Diallo |
| Informatique | Lambert |
| Informatique | Mercier |
| Juridique | NULL |
RIGHT JOIN garde toutes les lignes de la table de droite. A RIGHT JOIN B donne le même résultat que B LEFT JOIN A : par convention, on écrit presque toujours des LEFT JOIN, en plaçant à gauche la table dont on veut toutes les lignes.
Compter avec une jointure externe demande une précaution :
SELECT s.libelle,
COUNT(e.id_employe) AS effectif,
COUNT(*) AS faux_effectif
FROM service s
LEFT JOIN employe e ON e.id_service = s.id_service
GROUP BY s.id_service, s.libelle
ORDER BY s.id_service;
| libelle | effectif | faux_effectif |
|---|---|---|
| Direction | 1 | 1 |
| Comptabilité | 3 | 3 |
| Commercial | 3 | 3 |
| Logistique | 4 | 4 |
| Informatique | 3 | 3 |
| Juridique | 0 | 1 |
La ligne Juridique existe, avec NULL partout du côté employe. COUNT(*) compte cette ligne ; COUNT(e.id_employe) ignore le NULL et donne bien 0.
ON ou WHERE dans une jointure externe
On veut tous les employés, avec leur portable s'ils en ont un. Première tentative :
SELECT e.nom, p.num_inventaire
FROM employe e
LEFT JOIN poste p ON p.id_employe = e.id_employe
WHERE p.type_poste = 'portable';
Cette requête ne renvoie que 7 employés. Pour ceux qui n'ont pas de portable, p.type_poste vaut NULL ou 'fixe', et le WHERE les élimine : le LEFT JOIN est devenu un INNER JOIN. La condition doit aller dans le ON, pour restreindre les correspondances sans éliminer les lignes de gauche :
SELECT e.nom, p.num_inventaire
FROM employe e
LEFT JOIN poste p
ON p.id_employe = e.id_employe AND p.type_poste = 'portable'
ORDER BY e.nom, p.num_inventaire;
Elle renvoie les 14 employés, avec NULL pour les 7 qui n'ont pas de portable.
Trouver les lignes sans correspondance
La question « quels employés n'ont aucun poste ? » se traduit par une anti-jointure : une jointure externe, puis on garde les lignes où la table de droite n'a rien trouvé.
SELECT e.nom, e.prenom
FROM employe e
LEFT JOIN poste p ON p.id_employe = e.id_employe
WHERE p.id_poste IS NULL
ORDER BY e.nom;
| nom | prenom |
|---|---|
| Bonnet | Inès |
| Martin | Paul |
Teste une colonne de la table de droite qui ne peut pas être NULL quand la correspondance existe : sa clé primaire est le choix sûr. Même schéma pour les logiciels jamais installés (Acrobat Pro) ou les tickets sans intervention (7, 12 et 14). Le chapitre Sous-requêtes SQL écrit la même chose avec NOT EXISTS.
L'auto-jointure
Une auto-jointure joint une table à elle-même. Elle sert dès qu'une clé étrangère pointe vers sa propre table, comme employe.id_responsable. On utilise deux alias, un par rôle :
SELECT e.nom AS employe, r.nom AS responsable
FROM employe e
LEFT JOIN employe r ON r.id_employe = e.id_responsable
ORDER BY e.id_employe;
| employe | responsable |
|---|---|
| Garnier | NULL |
| Morel | Garnier |
| Lefèvre | Morel |
| Benali | Garnier |
| Roux | Benali |
| Petit | Benali |
| Faure | Garnier |
| Girard | Faure |
| Bonnet | Girard |
| Mercier | Garnier |
| Lambert | Mercier |
| Diallo | Mercier |
| Rousseau | Girard |
| Martin | Morel |
Le LEFT JOIN garde Hélène Garnier, la directrice, qui n'a pas de responsable. L'organigramme complet :
Joindre deux fois la même table
Une intervention fait intervenir deux employés : le demandeur du ticket et le technicien. La table employe apparaît donc deux fois, sous deux alias :
SELECT i.id_intervention, i.id_ticket,
dem.nom AS demandeur,
tech.nom AS technicien,
i.duree_min
FROM intervention i
JOIN ticket t ON t.id_ticket = i.id_ticket
JOIN employe dem ON dem.id_employe = t.id_demandeur
JOIN employe tech ON tech.id_employe = i.id_technicien
WHERE i.id_ticket IN (2, 9)
ORDER BY i.date_intervention;
| id_intervention | id_ticket | demandeur | technicien | duree_min |
|---|---|---|---|---|
| 3 | 2 | Morel | Diallo | 30 |
| 4 | 2 | Morel | Diallo | 40 |
| 11 | 9 | Faure | Mercier | 240 |
| 12 | 9 | Faure | Diallo | 180 |
| 13 | 9 | Faure | Lambert | 150 |
Choisis des alias parlants (dem, tech) plutôt que e1 et e2 : à la relecture, tu sais quel rôle joue chaque copie.
Jointures et agrégats
Joindre une table à deux tables enfants indépendantes multiplie les lignes. Sofiane Benali a 2 postes et a ouvert 2 tickets :
SELECT e.nom,
COUNT(p.id_poste) AS nb_postes,
COUNT(t.id_ticket) AS nb_tickets
FROM employe e
LEFT JOIN poste p ON p.id_employe = e.id_employe
LEFT JOIN ticket t ON t.id_demandeur = e.id_employe
WHERE e.id_service = 3
GROUP BY e.id_employe, e.nom
ORDER BY e.nom;
| nom | nb_postes | nb_tickets |
|---|---|---|
| Benali | 4 | 4 |
| Petit | 1 | 1 |
| Roux | 1 | 1 |
Chacun de ses 2 postes est combiné avec chacun de ses 2 tickets : 4 lignes, donc 4 et 4. COUNT(DISTINCT p.id_poste) et COUNT(DISTINCT t.id_ticket) rétablissent 2 et 2, mais DISTINCT ne sauve pas une somme : SUM(p.prix_achat) donnerait 3 296 € au lieu de 1 648 €. La solution robuste agrège chaque table enfant avant de joindre, dans des tables dérivées ou des CTE.
USING, NATURAL JOIN et l'ancienne syntaxe
Tu croiseras trois autres écritures. Sache les lire : USING s'emploie avec prudence, évite les deux autres.
-- USING : quand la colonne porte le même nom dans les deux tables
SELECT e.nom, s.libelle FROM employe e JOIN service s USING (id_service);
-- NATURAL JOIN : joint sur TOUTES les colonnes de même nom
SELECT nom, libelle FROM employe NATURAL JOIN service;
-- Ancienne syntaxe : tables séparées par des virgules, condition dans le WHERE
SELECT e.nom, s.libelle FROM employe e, service s WHERE s.id_service = e.id_service;
Les trois donnent le même résultat aujourd'hui. NATURAL JOIN est dangereux : si quelqu'un ajoute une colonne date_maj aux deux tables, la jointure portera aussi sur elle et renverra des résultats faux, sans erreur. L'ancienne syntaxe mélange conditions de jointure et filtres dans le WHERE : oublie une condition et tu obtiens un produit cartésien. Écris JOIN … ON.
FULL OUTER JOIN avec UNION
La jointure externe complète garde les lignes sans correspondance des deux côtés. MySQL et MariaDB ne la proposent pas ; on l'obtient en réunissant un LEFT JOIN et un RIGHT JOIN. Tous les employés et tous les postes, affectés ou non :
SELECT e.nom, p.num_inventaire
FROM employe e LEFT JOIN poste p ON p.id_employe = e.id_employe
UNION
SELECT e.nom, p.num_inventaire
FROM employe e RIGHT JOIN poste p ON p.id_employe = e.id_employe
ORDER BY nom, num_inventaire;
Le résultat compte 17 lignes : les 13 postes affectés, Inès Bonnet et Paul Martin avec NULL comme poste, et les postes PC-0013 et PC-0014 avec NULL comme nom (triés en premier, puisque MySQL place les NULL en tête). UNION supprime les lignes communes aux deux parties. PostgreSQL, SQL Server et SQLite écrivent directement FULL OUTER JOIN.
De la question à la jointure : la méthode
Prends une question d'examen : « Pour chaque service, le nombre de tickets non résolus ouverts par ses employés, y compris les services qui n'en ont aucun. »
- Ce qu'on affiche et ce qu'on filtre : le libellé du service, un nombre de tickets ; un filtre sur le statut.
- Les tables qui contiennent ces colonnes :
service(libellé),ticket(statut, comptage). - Le chemin entre elles dans le schéma :
service←employe←ticket. La tableemployeest indispensable, même si aucune de ses colonnes n'est affichée. - Interne ou externe : « y compris les services qui n'en ont aucun » impose
LEFT JOINdepuisservice. Et le filtre sur le statut, qui porte sur la table de droite, va dans leON. - Écrire dans l'ordre :
FROMet jointures,WHERE,GROUP BY,SELECT,ORDER BY.
SELECT s.libelle, COUNT(t.id_ticket) AS tickets_non_resolus
FROM service s
LEFT JOIN employe e ON e.id_service = s.id_service
LEFT JOIN ticket t ON t.id_demandeur = e.id_employe AND t.statut <> 'resolu'
GROUP BY s.id_service, s.libelle
ORDER BY tickets_non_resolus DESC, s.libelle;
| libelle | tickets_non_resolus |
|---|---|
| Logistique | 3 |
| Commercial | 1 |
| Comptabilité | 1 |
| Direction | 1 |
| Informatique | 0 |
| Juridique | 0 |
Exercices
Exercice 1 sur 5 Le parc et ses utilisateurs Réussi À revoir
Affiche le numéro d'inventaire de tous les postes avec le prénom et le nom de leur utilisateur, ou en stock s'ils ne sont affectés à personne. Trie par numéro d'inventaire.
Voir la réponseMasquer la réponse de l'exercice 1
SELECT p.num_inventaire,
COALESCE(CONCAT(e.prenom, ' ', e.nom), 'en stock') AS utilisateur
FROM poste p
LEFT JOIN employe e ON e.id_employe = p.id_employe
ORDER BY p.num_inventaire;
| num_inventaire | utilisateur |
|---|---|
| PC-0001 | Julie Lefèvre |
| PC-0002 | Hélène Garnier |
| PC-0003 | Karim Morel |
| PC-0004 | Sofiane Benali |
| PC-0005 | Camille Roux |
| PC-0006 | Thomas Petit |
| PC-0007 | Nadia Faure |
| PC-0008 | Lucas Girard |
| PC-0009 | Léa Rousseau |
| PC-0010 | Antoine Mercier |
| PC-0011 | Chloé Lambert |
| PC-0012 | Moussa Diallo |
| PC-0013 | en stock |
| PC-0014 | en stock |
| PC-0015 | Sofiane Benali |
Pour un poste en stock, CONCAT reçoit NULL et renvoie NULL : COALESCE le remplace. Avec un JOIN simple, les deux postes en stock disparaîtraient.
Tu avais trouvé ?
Exercice 2 sur 5 Les logiciels d'une personne Réussi À revoir
Affiche le nom des logiciels installés sur le ou les postes de Julie Lefèvre, avec leur date d'installation, par ordre alphabétique.
Voir la réponseMasquer la réponse de l'exercice 2
SELECT l.nom, i.date_installation
FROM employe e
JOIN poste p ON p.id_employe = e.id_employe
JOIN installation i ON i.id_poste = p.id_poste
JOIN logiciel l ON l.id_logiciel = i.id_logiciel
WHERE e.nom = 'Lefèvre' AND e.prenom = 'Julie'
ORDER BY l.nom;
| nom | date_installation |
|---|---|
| Firefox | 2019-02-14 |
| Microsoft 365 | 2019-02-14 |
| Sage 100 Comptabilité | 2019-02-14 |
Filtre sur le nom et le prénom : deux employés peuvent porter le même nom. Dans une application, tu filtrerais sur id_employe.
Tu avais trouvé ?
Exercice 3 sur 5 Les licences restantes Réussi À revoir
Pour chaque logiciel payant (nb_licences renseigné), affiche le nombre de licences, le nombre d'installations et le nombre de licences restantes, de la plus petite réserve à la plus grande.
Voir la réponseMasquer la réponse de l'exercice 3
SELECT l.nom, l.nb_licences,
COUNT(i.id_poste) AS utilisees,
l.nb_licences - COUNT(i.id_poste) AS restantes
FROM logiciel l
LEFT JOIN installation i ON i.id_logiciel = l.id_logiciel
WHERE l.nb_licences IS NOT NULL
GROUP BY l.id_logiciel, l.nom, l.nb_licences
ORDER BY restantes, l.nom;
| nom | nb_licences | utilisees | restantes |
|---|---|---|---|
| Odoo | 8 | 7 | 1 |
| Sage 100 Comptabilité | 3 | 2 | 1 |
| Acrobat Pro | 2 | 0 | 2 |
| Microsoft 365 | 12 | 10 | 2 |
Le LEFT JOIN garde Acrobat Pro, jamais installé, et COUNT(i.id_poste) lui donne 0. Avec COUNT(*), il afficherait 1 installation.
Tu avais trouvé ?
Exercice 4 sur 5 Les encadrants Réussi À revoir
Affiche le nom de chaque responsable et le nombre de personnes qu'il encadre directement, du plus grand nombre au plus petit.
Voir la réponseMasquer la réponse de l'exercice 4
SELECT r.nom, r.prenom, COUNT(*) AS nb_encadres
FROM employe e
JOIN employe r ON r.id_employe = e.id_responsable
GROUP BY r.id_employe, r.nom, r.prenom
ORDER BY nb_encadres DESC, r.nom;
| nom | prenom | nb_encadres |
|---|---|---|
| Garnier | Hélène | 4 |
| Benali | Sofiane | 2 |
| Girard | Lucas | 2 |
| Mercier | Antoine | 2 |
| Morel | Karim | 2 |
| Faure | Nadia | 1 |
e joue le rôle de l'employé encadré, r celui du responsable : on regroupe sur le responsable. La jointure interne écarte la directrice en tant qu'employée (elle n'a pas de responsable), ce qui est voulu.
Tu avais trouvé ?
Exercice 5 sur 5 Les services sans aucun ticket Réussi À revoir
Un collègue propose cette requête pour lister les services dont aucun employé n'a ouvert de ticket :
SELECT s.libelle
FROM service s
LEFT JOIN employe e ON e.id_service = s.id_service
LEFT JOIN ticket t ON t.id_demandeur = e.id_employe
WHERE t.id_ticket IS NULL;
Elle renvoie Informatique (trois fois), Juridique et Logistique. Explique l'erreur et corrige-la.
Voir la réponseMasquer la réponse de l'exercice 5
La requête cherche les employés sans ticket, pas les services. Inès Bonnet n'a ouvert aucun ticket : sa ligne passe le filtre et fait apparaître la Logistique, alors que ses collègues en ont ouvert quatre. Les trois techniciens font apparaître trois fois l'Informatique.
Raisonne au niveau du service : regrouper par service et garder ceux dont le nombre de tickets est nul.
SELECT s.libelle
FROM service s
LEFT JOIN employe e ON e.id_service = s.id_service
LEFT JOIN ticket t ON t.id_demandeur = e.id_employe
GROUP BY s.id_service, s.libelle
HAVING COUNT(t.id_ticket) = 0
ORDER BY s.libelle;
| libelle |
|---|
| Informatique |
| Juridique |
Le chapitre Sous-requêtes SQL en donne une écriture plus directe avec NOT EXISTS.
Tu avais trouvé ?