Leçon 5/7 9 min

Les jointures

Comprenez pourquoi les tables sont séparées, les clés étrangères, et reliez-les avec INNER JOIN, LEFT JOIN et la clause ON.

Le problème : le nom du client n'est pas dans la commande

Vous voulez afficher « Marie Dupont a commandé pour 89,90 € ». Mais la table commandes ne contient pas le nom de Marie. Elle contient seulement un client_id = 1. Le nom, lui, est dans la table clients.

C'est volontaire. Si on stockait « Marie Dupont » dans chaque commande, et qu'elle se marie et change de nom, il faudrait modifier des milliers de lignes. En séparant, on écrit le nom une seule fois. C'est le principe des relations. Pour rassembler les morceaux au moment de l'affichage, on utilise une jointure.

clients                      commandes
id | nom            | ville   id | client_id | montant
---+----------------+------   ---+-----------+--------
1  | Marie Dupont   | Besac.  1  | 1         | 89.90
2  | Karim Benali   | Dijon   2  | 2         | 42.00
3  | Léa Martin     | Lyon    3  | 1         | 120.50

La clé étrangère : le pont entre deux tables

Dans commandes, la colonne client_id est une clé étrangère (foreign key) : elle pointe vers la clé primaire id de la table clients. C'est le fil qui relie une commande à son client.

Une clé primaire identifie une ligne dans SA table. Une clé étrangère est une copie de cet identifiant rangée dans une AUTRE table pour créer le lien. commandes.client_id référence clients.id.

INNER JOIN : ne garder que les correspondances

Un INNER JOIN assemble deux tables sur une condition de correspondance définie par ON. Il ne garde que les lignes qui matchent des deux côtés.

Prédisez avant de lire

Une table clients contient 10 clients ; 3 d'entre eux n'ont passé aucune commande (aucune ligne dans commandes). Avant de dérouler : combien de clients voit-on dans le résultat de SELECT * FROM clients INNER JOIN commandes ON commandes.client_id = clients.id : les 10, ou seulement 7 ? Et qu'obtiendrait-on avec un LEFT JOIN à la place ?

Voir la réponse

Un INNER JOIN ne garde que les lignes qui ont une correspondance des deux côtés : les 3 clients sans commande disparaissent du résultat. On ne voit donc que les 7 clients ayant au moins une commande (et une ligne par commande). Un LEFT JOIN (clients à gauche) garde tous les clients, y compris les 3 sans commande ; pour ceux-là, les colonnes venant de commandes valent NULL. Choisir le bon JOIN dépend de la question : « les clients qui ont commandé » (INNER) ou « tous les clients, avec leurs commandes éventuelles » (LEFT). Oublier ce détail fait silencieusement disparaître des lignes.

SELECT c.nom, cmd.montant
FROM clients AS c
INNER JOIN commandes AS cmd
    ON cmd.client_id = c.id
ORDER BY c.nom;

On lit : « pour chaque commande, va chercher dans clients la ligne dont l'id égale le client_id, et colle les colonnes ensemble ». Marie apparaît deux fois (elle a deux commandes), Léa n'apparaît pas (elle n'a aucune commande).

Le piège qui fait le plus mal : oublier la clause ON. Sans elle, la base associe chaque ligne de gauche à chaque ligne de droite (un « produit cartésien ») : 100 clients × 1000 commandes donnent 100 000 lignes de bouillie, et aucune erreur ne s'affiche. Une jointure a toujours besoin d'un ON qui dit comment relier les deux tables.

Toujours qualifier les colonnes avec des alias de table. Écrivez c.nom et cmd.montant plutôt que nom et montant. Si les deux tables ont une colonne du même nom (comme id), une requête non qualifiée est ambiguë et échoue. Les alias rendent aussi la requête bien plus lisible.

LEFT JOIN : garder tout le monde, même sans correspondance

Et si on veut tous les clients, y compris ceux qui n'ont jamais commandé ? L'INNER JOIN les oublie. Le LEFT JOIN garde toutes les lignes de la table de gauche (clients), et met NULL dans les colonnes de droite quand il n'y a pas de correspondance.

Diagrammes de Venn comparant INNER JOIN (seule l'intersection clients-commandes est gardée) et LEFT JOIN (tous les clients plus l'intersection). INNER JOIN clients commandes seulement les lignes qui correspondent des deux côtés LEFT JOIN clients commandes tous les clients, même sans commande
En vert, les lignes conservées : l'INNER JOIN ne garde que l'intersection, le LEFT JOIN garde tous les clients.
SELECT c.nom, cmd.montant
FROM clients AS c
LEFT JOIN commandes AS cmd
    ON cmd.client_id = c.id
ORDER BY c.nom;

Cette fois, Léa apparaît avec un montant à NULL. On combine alors ce LEFT JOIN avec l'agrégation déjà vue en leçon 4 (SUM + GROUP BY) pour répondre à « combien chaque client a-t-il dépensé, zéro inclus ». La seule vraie nouveauté ici, c'est COALESCE :

SELECT c.nom, COALESCE(SUM(cmd.montant), 0) AS total
FROM clients AS c
LEFT JOIN commandes AS cmd
    ON cmd.client_id = c.id
GROUP BY c.id, c.nom
ORDER BY total DESC;

COALESCE(valeur, 0) remplace un NULL par 0. Pratique pour qu'un client sans commande affiche un total de 0 au lieu de NULL.

Les sous-requêtes : une requête dans une requête

Une sous-requête est un SELECT imbriqué entre parenthèses à l'intérieur d'une autre requête. Le moteur SQL exécute d'abord la sous-requête, puis utilise le résultat pour compléter la requête extérieure.

Cas 1 : sous-requête scalaire. Elle renvoie une seule valeur et s'utilise dans un WHERE avec une comparaison classique. Par exemple, « les produits plus chers que la moyenne » :

SELECT nom, prix
FROM produits
WHERE prix > (SELECT AVG(prix) FROM produits);

La sous-requête (SELECT AVG(prix) FROM produits) est évaluée en premier et renvoie un nombre (la moyenne). Ce nombre sert ensuite à filtrer la requête principale. Exécutez ci-dessous pour le vérifier :

CREATE TABLE produits (id INTEGER, nom TEXT, prix REAL);
INSERT INTO produits VALUES (1, 'Stylo', 1.50);
INSERT INTO produits VALUES (2, 'Cahier', 3.20);
INSERT INTO produits VALUES (3, 'Cartable', 28.00);
INSERT INTO produits VALUES (4, 'Règle', 0.90);

SELECT nom, prix
FROM produits
WHERE prix > (SELECT AVG(prix) FROM produits);

📝 Une sous-requête scalaire doit renvoyer exactement une valeur (une seule ligne, une seule colonne). Si elle en renvoie plusieurs, la base retourne une erreur. AVG, MAX, MIN, COUNT et SUM garantissent toujours une valeur unique, d'où leur usage fréquent dans ce contexte.

Cas 2 : sous-requête liste avec IN. Elle renvoie plusieurs valeurs et filtre les lignes dont une colonne appartient à cette liste. Par exemple, « les clients qui ont déjà commandé » :

SELECT nom
FROM clients
WHERE id IN (SELECT client_id FROM commandes);

La sous-requête (SELECT client_id FROM commandes) produit la liste de tous les identifiants clients présents dans les commandes. La requête principale ne garde que les clients dont l'id figure dans cette liste :

CREATE TABLE clients (id INTEGER, nom TEXT);
CREATE TABLE commandes (id INTEGER, client_id INTEGER, montant REAL);
INSERT INTO clients VALUES (1, 'Alice Durand');
INSERT INTO clients VALUES (2, 'Marc Petit');
INSERT INTO clients VALUES (3, 'Léa Martin');
INSERT INTO commandes VALUES (1, 1, 120.50);
INSERT INTO commandes VALUES (2, 2, 45.00);

SELECT nom
FROM clients
WHERE id IN (SELECT client_id FROM commandes);

Sous-requête ou jointure ? Les jointures (vues dans les sections précédentes) combinent les colonnes de deux tables pour afficher des données issues des deux. Les sous-requêtes servent plutôt à filtrer selon une valeur ou une liste calculée par une autre requête. Les deux se recouvrent parfois : le WHERE id IN (SELECT client_id FROM commandes) peut souvent se réécrire en INNER JOIN. La sous-requête est souvent plus lisible quand l'objectif est un simple filtre et qu'on n'a pas besoin d'afficher des colonnes de la seconde table.

The problem: the customer name is not in the order

You want to display "Marie Dupont ordered for 89.90 EUR". But the commandes table does not contain Marie's name. It only contains a client_id = 1. The name lives in the clients table.

This is on purpose. If we stored "Marie Dupont" in every order, and she married and changed her name, we would have to update thousands of rows. By splitting, we write the name only once. This is the principle of relations. To reassemble the pieces at display time, we use a join.

clients                      commandes
id | nom            | ville   id | client_id | montant
---+----------------+------   ---+-----------+--------
1  | Marie Dupont   | Besac.  1  | 1         | 89.90
2  | Karim Benali   | Dijon   2  | 2         | 42.00
3  | Lea Martin     | Lyon    3  | 1         | 120.50

The foreign key: the bridge between two tables

In commandes, the client_id column is a foreign key: it points to the primary key id of the clients table. It is the thread linking an order to its customer.

A primary key identifies a row in ITS table. A foreign key is a copy of that identifier stored in ANOTHER table to create the link. commandes.client_id references clients.id.

INNER JOIN: keep only matches

An INNER JOIN assembles two tables on a match condition defined by ON. It keeps only the rows that match on both sides.

Predict before reading

A table clients holds 10 customers; 3 of them have never placed any order (no row in commandes). Before scrolling: how many customers appear in the result of SELECT * FROM clients INNER JOIN commandes ON commandes.client_id = clients.id — all 10, or only 7? And what would a LEFT JOIN give instead?

See the answer

An INNER JOIN keeps only the rows that match on both sides: the 3 customers with no order disappear from the result. You see only the 7 customers who have at least one order (one row per order). A LEFT JOIN (customers on the left) keeps all customers, including the 3 with no order; for those, the columns coming from commandes are NULL. Choosing the right JOIN depends on the question: "customers who ordered" (INNER) or "all customers, with their orders if any" (LEFT). Forgetting this silently drops rows.

SELECT c.nom, cmd.montant
FROM clients AS c
INNER JOIN commandes AS cmd
    ON cmd.client_id = c.id
ORDER BY c.nom;

Read it as: "for each order, fetch from clients the row whose id equals the client_id, and stick the columns together". Marie appears twice (she has two orders), Lea does not appear (she has no order).

The trap that hurts most: forgetting the ON clause. Without it, the database pairs every left row with every right row (a "cartesian product"): 100 customers × 1000 orders give 100,000 rows of garbage, and no error shows. A join always needs an ON telling it how to link the two tables.

Always qualify columns with table aliases. Write c.nom and cmd.montant rather than nom and montant. If both tables have a column with the same name (like id), an unqualified query is ambiguous and fails. Aliases also make the query far more readable.

LEFT JOIN: keep everyone, even without a match

What if we want all customers, including those who never ordered? The INNER JOIN forgets them. The LEFT JOIN keeps every row of the left table (clients), and puts NULL in the right columns when there is no match.

Venn diagrams comparing INNER JOIN (only the customers-orders intersection is kept) and LEFT JOIN (all customers plus the intersection). INNER JOIN customers orders only the rows that match on both sides LEFT JOIN customers orders all customers, even without an order
In green, the rows kept: INNER JOIN keeps only the intersection, LEFT JOIN keeps every customer.
SELECT c.nom, cmd.montant
FROM clients AS c
LEFT JOIN commandes AS cmd
    ON cmd.client_id = c.id
ORDER BY c.nom;

This time, Lea appears with a NULL montant. We then combine this LEFT JOIN with the aggregation already seen in lesson 4 (SUM + GROUP BY) to answer "how much did each customer spend, zero included". The only genuinely new piece here is COALESCE:

SELECT c.nom, COALESCE(SUM(cmd.montant), 0) AS total
FROM clients AS c
LEFT JOIN commandes AS cmd
    ON cmd.client_id = c.id
GROUP BY c.id, c.nom
ORDER BY total DESC;

COALESCE(value, 0) replaces a NULL with 0. Handy so a customer with no order shows a total of 0 instead of NULL.

Subqueries: a query inside a query

A subquery is a SELECT nested inside parentheses within another query. The SQL engine runs the subquery first, then uses the result to complete the outer query.

Case 1: scalar subquery. It returns a single value and is used in a WHERE with a regular comparison. For example, "products more expensive than the average":

SELECT nom, prix
FROM produits
WHERE prix > (SELECT AVG(prix) FROM produits);

The subquery (SELECT AVG(prix) FROM produits) is evaluated first and returns a number (the average). That number is then used to filter the main query. Run it below to see it in action:

CREATE TABLE produits (id INTEGER, nom TEXT, prix REAL);
INSERT INTO produits VALUES (1, 'Stylo', 1.50);
INSERT INTO produits VALUES (2, 'Cahier', 3.20);
INSERT INTO produits VALUES (3, 'Cartable', 28.00);
INSERT INTO produits VALUES (4, 'Regle', 0.90);

SELECT nom, prix
FROM produits
WHERE prix > (SELECT AVG(prix) FROM produits);

📝 A scalar subquery must return exactly one value (one row, one column). If it returns more, the database raises an error. AVG, MAX, MIN, COUNT and SUM always guarantee a single value, which is why they are common in this context.

Case 2: list subquery with IN. It returns multiple values and filters rows whose column value belongs to that list. For example, "customers who have already placed an order":

SELECT nom
FROM clients
WHERE id IN (SELECT client_id FROM commandes);

The subquery (SELECT client_id FROM commandes) produces the list of all customer IDs present in orders. The outer query keeps only the customers whose id appears in that list:

CREATE TABLE clients (id INTEGER, nom TEXT);
CREATE TABLE commandes (id INTEGER, client_id INTEGER, montant REAL);
INSERT INTO clients VALUES (1, 'Alice Durand');
INSERT INTO clients VALUES (2, 'Marc Petit');
INSERT INTO clients VALUES (3, 'Lea Martin');
INSERT INTO commandes VALUES (1, 1, 120.50);
INSERT INTO commandes VALUES (2, 2, 45.00);

SELECT nom
FROM clients
WHERE id IN (SELECT client_id FROM commandes);

Subquery or join? Joins (seen in the previous sections) combine columns from two tables to display data from both. Subqueries are better suited to filtering based on a value or list computed by another query. The two overlap sometimes: WHERE id IN (SELECT client_id FROM commandes) can often be rewritten as an INNER JOIN. The subquery is often more readable when the goal is a simple filter and you do not need to display columns from the second table.

À vous d'essayer, la base est déjà remplie (Léa n'a aucune commande). Avant de cliquer sur Exécuter, prédisez qui va apparaître. Puis remplacez INNER JOIN par LEFT JOIN, relancez, et regardez qui surgit en plus, et avec quelle valeur.

CREATE TABLE clients (id INTEGER, nom TEXT);
CREATE TABLE commandes (id INTEGER, client_id INTEGER, montant REAL);
INSERT INTO clients VALUES (1, 'Alice Durand');
INSERT INTO clients VALUES (2, 'Marc Petit');
INSERT INTO clients VALUES (3, 'Léa Martin');   -- aucune commande
INSERT INTO commandes VALUES (1, 1, 120.50);
INSERT INTO commandes VALUES (2, 1, 80.00);
INSERT INTO commandes VALUES (3, 2, 45.00);

SELECT clients.nom, commandes.montant
FROM clients
INNER JOIN commandes ON commandes.client_id = clients.id
ORDER BY clients.nom;

🎯 Pratique

S'entraîner (clique pour ouvrir) :

Prompt IA
Avec l'IA

Demandez la jointure à l'IA et vérifiez le type (INNER vs LEFT) et les alias de table :

J'ai deux tables : clients (id, nom, ville) et commandes (id, client_id, montant), où commandes.client_id référence clients.id. Écris une requête SQL qui liste TOUS les clients avec le total de leurs achats, y compris ceux qui n'ont jamais commandé (total 0). Utilise des alias de table et qualifie chaque colonne. Précise pourquoi tu choisis LEFT JOIN plutôt qu'INNER JOIN.
💬 Ré-explique sans regarder
Ré-explique sans regarder

Sans relire la réponse de l'IA : avec tes mots, pourquoi a-t-elle choisi LEFT JOIN plutôt qu'INNER JOIN pour lister TOUS les clients, même ceux à 0 € ?

Une bonne explication dit : un INNER JOIN ne garde que les clients qui ont au moins une commande, donc les clients sans commande disparaissent du résultat. Le LEFT JOIN garde toutes les lignes de la table de gauche (clients) et met NULL à droite quand il n'y a pas de correspondance ; combiné à COALESCE(SUM(...), 0), le client sans achat affiche bien un total de 0 au lieu de disparaître.
Exercice : Joignez les tables

Écrivez une requête qui affiche le nom du client et le montant de chaque commande, uniquement pour les clients ayant au moins une commande. Utilisez un INNER JOIN, la clause ON (cmd.client_id = c.id) et des alias de table.

⚖️ Juge le code de l'IA
Accepter ou rejeter le code de l'IA

Tu lui as demandé : « liste TOUS les clients avec leur total dépensé, ceux sans commande inclus ». L'IA renvoie ceci. Tu l'acceptes tel quel ou tu le rejettes ?

SELECT c.nom, SUM(cmd.montant) AS total
FROM clients AS c
INNER JOIN commandes AS cmd
    ON cmd.client_id = c.id
GROUP BY c.id, c.nom
ORDER BY total DESC;
À rejeter. La demande disait « ceux sans commande inclus », mais l'INNER JOIN ne garde que les clients ayant au moins une commande : un client à 0 € disparaît silencieusement du résultat. C'est le piège classique de la jointure, et le plus sournois car la requête tourne sans erreur. Deux corrections : passer en LEFT JOIN pour garder tous les clients, et envelopper la somme dans COALESCE(SUM(cmd.montant), 0) pour afficher 0 au lieu de NULL.
🧠 Rappel libre
Rappel libre

Sans remonter dans la leçon : quelle est la différence entre INNER JOIN et LEFT JOIN, et à quoi sert la clause ON ?

L'INNER JOIN ne garde que les lignes qui ont une correspondance des deux côtés (un client SANS commande disparaît). Le LEFT JOIN garde toutes les lignes de la table de gauche et met NULL à droite quand il n'y a pas de correspondance. La clause ON définit la condition de rapprochement, ici cmd.client_id = c.id : c'est elle qui dit quelles lignes des deux tables vont ensemble.
Qu'est-ce qu'une clé étrangère ?
Quelle jointure garde les clients sans aucune commande ?
Pourquoi qualifier les colonnes (c.nom, cmd.montant) ?
Prochaine étape

Jusqu'ici vous n'avez fait que lire. La prochaine leçon vous donne les pleins pouvoirs : ajouter, modifier et supprimer des lignes avec INSERT, UPDATE et DELETE, sans vider toute la table par accident.

Leçon 6 : Modifier les données →

Une erreur dans cette leçon, un passage flou, une question ? Écrivez-moi : chaque retour améliore ce cours.

Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement