Tous les cours
Les données · Palier 1 · Lire les données — Voir le parcours

Quiz d'entraînement : les trois quarts des bonnes réponses pour le réussir.

SQL : interroger une base

La plupart des données d'une entreprise vivent dans des bases relationnelles : des tables, reliées entre elles. Pour les interroger, on écrit du SQL, un langage déclaratif : on décrit le résultat voulu, et c'est la base qui choisit comment le calculer. Le modèle date de 1970 (Codd) et il n'a pas pris une ride : une boutique en ligne, une banque ou une application de livraison rangent leurs clients, leurs commandes et leurs paiements dans des tables.

Ce cours ne cherche pas à te faire retenir une syntaxe. Il suit cinq mécanismes qui expliquent la plupart des surprises : l'ordre dans lequel la base traite une requête, la valeur inconnue NULL, le regroupement, la jointure, et le plan d'exécution qui décide de la vitesse. Tu n'as besoin de rien pour commencer : savoir lire un tableau suffit.

Le fil du cours : trois tables d'une petite boutique en ligne imaginaire, clients, commandes et paiements. Les figures calculent pour de vrai, avec un petit moteur SQL jouet dont les résultats sont comparés à ceux du vrai SQLite sur 55 requêtes (relevé du 21 octobre 2026, SQLite 3.54.1). Et dans le bac à sable, tu écris tes propres requêtes.


Des tables, des lignes, des clés

Une table a des colonnes, chacune d'un type (texte, entier, date…), et des lignes. Chaque ligne est identifiée par une clé primaire (souvent id). Une table peut en désigner une autre par une clé étrangère : la colonne client_id de commandes contient l'id d'un client. Voici les trois tables de la boutique.

clients

idnomville
1AnaParis
2BrunoLyon
3ChloéNULL
4DavidParis
5EmmaMarseille

commandes

idclient_idmontant
101130
102120
103250
104415
105940

paiements

commande_idmontant_paye
10115
10115
10350

Trois détails sont plantés exprès dans ces données, et le cours les retrouvera. La ville de Chloé est NULL : on ne la connaît pas. La commande 105 désigne un client 9 qui n'existe pas (une base peut l'interdire avec une contrainte, comme le montre Les bases de données : modéliser et faire évoluer ; ici elle ne le fait pas, pour qu'on voie ce que les jointures en font). Et la commande 101 est payée en deux fois : elle a deux lignes dans paiements.


Lire : SELECT, FROM, WHERE

SELECT nom, ville
FROM clients
WHERE ville = 'Paris'
ORDER BY nom
LIMIT 10;

Cette requête dit trois choses : quelles colonnes (SELECT), de quelle table (FROM), pour quelles lignes (WHERE). Puis elle trie (ORDER BY) et en garde dix au plus (LIMIT). Sans ORDER BY, l'ordre des lignes n'est pas garanti : la base rend celui qui lui est le plus rapide, et il peut changer d'un jour à l'autre. Un LIMIT sans ORDER BY donne donc dix lignes quelconques.

On écrit le SELECT en premier, mais la base ne commence pas par lui. Elle lit d'abord la table (FROM), écarte les lignes qui échouent à la condition (WHERE), calcule alors seulement les colonnes demandées (SELECT), trie (ORDER BY), puis coupe (LIMIT). La figure suit cet ordre sur une requête qui ajoute 5 € de livraison au montant sous le nom avec_frais. Avance le jeton d'une étape à l'autre et regarde quels noms existent déjà à chacune : dans quel ordre, au juste, la base fait-elle les choses ?

Figure interactiveL'ordre dans lequel la base travailleAvance le jeton d'une étape à l'autre et regarde quels noms existent déjà.
traitée en position 3SELECT id, montant + 5 AS avec_fraistraitée en position 1FROM commandestraitée en position 2WHERE montant >= 20traitée en position 4ORDER BY avec_frais DESCtraitée en position 5LIMIT 2

Le chiffre devant chaque ligne est le rang où la base la traite.

FROM5 lignesWHERESELECTORDER BYLIMIT
Lignes à l'étape FROM
idclient_idmontant
101130
102120
103250
104415
105940

Étape 1 sur 5

FROM : lire la table

La base commence par la table : cinq commandes, trois colonnes. Rien n'est encore filtré, rien n'est calculé.

Une requête de cinq clauses sur la table commandes : on ajoute 5 € de livraison au montant (avec_frais), on garde les commandes d'au moins 20 €, on trie et on garde deux lignes. Chaque tableau est calculé ; le résultat final est celui de SQLite.

Le nom avec_frais n'existe qu'à partir du SELECT. PostgreSQL refuse donc un alias dans un WHERE, alors que ORDER BY peut l'utiliser, puisqu'il passe après. SQLite, plus tolérant, laisse l'alias passer dans le WHERE en le remplaçant par son expression, et la console du bac à sable fait comme lui, en te prévenant : ne compte pas dessus, la même requête échouerait ailleurs. L'ordre a une autre conséquence : LIMIT s'applique en dernier, donc ORDER BY … LIMIT 2 donne les deux plus grandes valeurs, et non les deux premières lignes lues.


NULL : la valeur inconnue

NULL n'est pas une valeur : c'est l'absence de valeur. En SQL, une comparaison avec NULL ne vaut ni vrai ni faux : elle vaut inconnu. C'est la logique à trois valeurs. Et WHERE ne garde que le vrai : une ligne dont la condition est inconnue est écartée, comme si elle était fausse.

Dans la boutique, la ville de Chloé est inconnue. Avant de jouer, la question de la figure : sur nos cinq clients, combien de lignes renvoie WHERE ville <> 'Paris' ? Parie, puis range chaque client dans son couloir en changeant de condition.

Figure interactiveNULL dans un filtreFais ton pari, puis essaie chaque condition et regarde dans quel couloir tombe Chloé.

Cinq clients, dont Chloé, dont la ville est inconnue (NULL). Combien de lignes renvoie WHERE ville <> 'Paris' ?

Parie d'abord : la figure se dévoile après ta réponse.

Cinq clients ; la ville de Chloé est inconnue (NULL). Pour chaque ligne, la condition vaut VRAI, FAUX ou INCONNU ; WHERE ne garde que VRAI. Résultats comparés à SQLite.

Pour la base, on ne sait pas si Chloé habite à Paris : ni ville = 'Paris' ni ville <> 'Paris' n'est vrai pour elle, et WHERE écarte l'inconnu des deux côtés. Voilà pourquoi ces deux conditions ne couvrent à elles deux que quatre clients sur cinq. Le piège le plus dur est celui de NOT IN : avec un seul NULL dans la liste, chaque ligne est fausse ou inconnue, jamais vraie, et la requête ne renvoie plus personne, sans une erreur. La documentation de PostgreSQL le dit noir sur blanc : un IN sans valeur égale mais avec un NULL dans la liste donne NULL, pas faux. Les réflexes qui en découlent :

  • tester une valeur manquante avec IS NULL ou IS NOT NULL, jamais avec = NULL ;
  • COUNT(*) compte les lignes, COUNT(colonne) seulement les valeurs qui ne sont pas NULL ;
  • sauf COUNT, un agrégat sur zéro ligne renvoie NULL, pas 0 : on écrit COALESCE(SUM(montant), 0) ;
  • préférer NOT EXISTS à NOT IN avec une sous-requête, qui peut contenir un NULL.

Regrouper : GROUP BY et HAVING

GROUP BY réunit les lignes qui ont la même valeur, et les agrégats les résument : COUNT, SUM, AVG, MIN, MAX. Compter les clients par ville donne une ligne par ville, et Chloé, dont la ville est inconnue, forme son propre groupe NULL. C'est le contraire du NULL = NULL inconnu de la section précédente : pour regrouper (comme pour DISTINCT), les NULL comptent comme égaux entre eux. Il faut bien les ranger quelque part.

Deux clauses filtrent, à deux moments. WHERE écarte des lignes, avant le regroupement ; HAVING écarte des groupes, après les totaux. Un agrégat n'a donc pas sa place dans un WHERE : cette clause est évaluée avant que les agrégats existent, PostgreSQL le refuse, et SQLite aussi. Voici la question : on veut le total par client, en ne gardant que ce qui dépasse 25 €. Parie d'abord, posée au WHERE, la condition montant > 25 change-t-elle le total du client 1 ? Puis fais avancer les cinq commandes dans la requête, et bascule la même condition au HAVING.

Figure interactiveOù poser la condition : WHERE ou HAVINGFais ton pari, puis fais avancer les commandes étape par étape. Bascule la même condition de place.

On veut le total par client, en ne gardant que les commandes de plus de 25 € (WHERE montant > 25, puis GROUP BY client_id). Le client 1 a deux commandes : 30 € et 20 €. Quel total la base calcule-t-elle pour lui ?

Parie d'abord : la figure se dévoile après ta réponse.

Cinq commandes (le numéro en haut, le montant dessous). La même condition, montant > 25, posée avant ou après le regroupement. Groupes et totaux calculés ; résultats comparés à SQLite.

Au WHERE, la commande de 20 € est écartée avant que le groupe du client 1 existe : son total tombe à 30 €. Au HAVING, le groupe est formé en entier (50 €), puis jugé, et il survit. Dans les deux cas le client 4 disparaît, parce que sa seule commande ne pèse que 15 €, mais seul le HAVING laisse les totaux intacts. Règle pratique : le WHERE filtre les valeurs brutes, le HAVING filtre les totaux. Et tout ce que le SELECT affiche est soit dans le GROUP BY, soit agrégé : sinon la base ne sait pas quelle valeur du groupe montrer.


Joindre des tables

Les commandes sont dans une table, les clients dans une autre. Une jointure les met côte à côte, en suivant la clé étrangère : chaque commande est appariée au client dont l'id est son client_id. Le type de jointure décide du sort des lignes qui n'ont pas de partenaire :

  • INNER JOIN : seulement les paires qui se correspondent ;
  • LEFT JOIN : toute la table de gauche, avec NULL en face quand il n'y a rien ;
  • RIGHT JOIN : l'inverse (en pratique, on échange les deux tables et on écrit un LEFT JOIN) ;
  • FULL JOIN : tout, des deux côtés.

Dans la boutique, Ana a deux commandes, Chloé et Emma n'en ont aucune, et la commande 105 n'a pas de client. Avant la figure, parie : combien de lignes renvoie clients LEFT JOIN commandes ? Puis ajoute toi-même des commandes, en changeant leur client_id, et regarde les fils se tendre.

Figure interactiveUne jointure, des fils entre deux tablesFais ton pari, puis change le type de jointure et ajoute des commandes.

clients LEFT JOIN commandes : combien de lignes ? Ana a deux commandes, Chloé et Emma aucune, et la 105 vient d'un client 9 qui n'existe pas.

Parie d'abord : la figure se dévoile après ta réponse.

Cinq clients à gauche, les commandes à droite, un fil pour chaque client_id qui correspond (chaque commande : son numéro, → son client_id, et entre parenthèses son montant). Lignes calculées par un petit moteur, comparées à SQLite sur dix scénarios d'ajout.

Six, et pas cinq : un LEFT JOIN garantit que chaque client apparaît au moins une fois, pas exactement une. Ana, avec deux commandes, donne deux lignes ; Chloé et Emma, sans commande, en donnent une chacune, avec NULL. Un client donne donc max(1, nombre de commandes) lignes. Ajoute une commande pour Chloé : sa ligne NULL est remplacée, et le total ne bouge pas. Ajoute-en une seconde, et il monte. Ce NULL sert aussi à chercher l'absence : LEFT JOIN … WHERE o.id IS NULL renvoie les clients sans commande.

Un piège voisin se glisse dans le WHERE d'un LEFT JOIN. Une condition sur la table de droite, écrite là, est inconnue pour les lignes NULL et les écarte : la jointure redevient un INNER JOIN sans le dire. Pour garder les clients sans commande, la condition va dans le ON. Les jointures RIGHT et FULL ne sont arrivées dans SQLite qu'en juin 2022 (version 3.39), et la console du bac à sable ne les connaît pas.


Agréger avant de joindre

Une jointure vers une table « plusieurs » multiplie les lignes. Les paiements en sont une : la commande 101 est payée en deux fois, donc elle apparaît sur deux lignes quand on les joint. Une somme calculée après coup compte alors son montant deux fois, sans que la base lève la moindre erreur.

On veut le reste à encaisser : le total des commandes moins le total payé. Les commandes pèsent 155 €, les paiements 80 €. La requête naïve joint les paiements aux commandes, puis calcule SUM(o.montant) - SUM(p.montant_paye). Parie : que renvoie-t-elle ?

Figure interactiveLe reste à encaisser : joindre d'abord, ou sommer d'abordFais ton pari, puis compare les deux barres : chaque segment est une ligne de la jointure, en ambre quand la commande est déjà comptée. Le trait pointillé marque le vrai total, 155 €.

On veut le reste à encaisser : le total des commandes (155 €) moins le total payé (80 €). La requête joint les paiements aux commandes, puis calcule SUM(o.montant) − SUM(p.montant_paye). Que renvoie-t-elle ?

Parie d'abord : la figure se dévoile après ta réponse.

Commandes : 101 (30 €), 102 (20 €), 103 (50 €), 104 (15 €), 105 (40 €), soit 155 €. Paiements : 15 € + 15 € pour la 101, 50 € pour la 103, soit 80 €. Les lignes en ambre sont celles d'une commande qui occupe plusieurs lignes. Sommes comparées à SQLite.

105 € au lieu de 75 € : la commande 101 (30 €) est comptée deux fois, et le total des commandes passe de 155 à 185. Le remède est d'agréger avant de joindre : on additionne d'abord les paiements par commande dans une sous-requête, qui rend une ligne par commande, et seulement ensuite on joint. La question à se poser avant toute jointure est « une ligne de cette table représente quoi ? ». Ici une ligne de paiements est un paiement, une ligne de commandes est une commande : en les joignant, on change la granularité, et chaque somme du côté « commande » devient suspecte. COUNT(DISTINCT o.id) rattrape un comptage, jamais une somme.


Pourquoi une requête est lente : le plan

Comme SQL décrit le résultat et pas la méthode, la base choisit sa méthode : c'est le plan d'exécution, que EXPLAIN affiche. Il y a deux grandes façons de trouver des lignes. On peut lire toute la table et tester chaque ligne (SQLite écrit SCAN, PostgreSQL Seq Scan). Ou on peut sauter directement aux bonnes lignes grâce à un index, une copie rangée d'une colonne (SEARCH … USING INDEX pour SQLite, Index Scan pour PostgreSQL). Sur cinq lignes, la différence ne se voit pas ; sur des millions, c'est elle qui fait attendre.

La boutique a maintenant cinq ans de commandes, chacune avec sa date, et un index sur cette date. Trois recherches se présentent. Parie : laquelle la base fait-elle encore en lisant toute la table ? Puis change l'index et regarde les cases lues.

Figure interactiveLire toute la table, ou chercher par l'indexFais ton pari, puis change l'index et regarde les cases lues.

Les commandes ont un index sur la date. Parmi ces trois recherches, laquelle la base fait-elle encore en lisant toute la table ?

Parie d'abord : la figure se dévoile après ta réponse.

Des commandes sur cinq ans, à volume régulier : une case = un mois de commandes, colorée quand la base doit le lire. Les lignes de plan sont celles de SQLite 3.54.1 (EXPLAIN QUERY PLAN) sur 5 000 commandes, relevées le 21 octobre 2026 ; PostgreSQL les écrit Seq Scan et Index Scan.

La troisième. L'index est rangé par date_commande, pas par strftime('%Y-%m', date_commande) : pour comparer le résultat de la fonction à '2026-09', la base devrait la calculer sur chaque ligne, donc les lire toutes. Une fonction appliquée à la colonne indexée désactive l'index. Deux remèdes : récrire la condition sans fonction, en plage de dates (la deuxième requête), ou créer un index sur l'expression elle-même (le troisième état de la figure), ce que SQLite comme PostgreSQL savent faire. Cet index-là, en retour, ne sert plus la plage. Un index a un coût (chaque écriture doit aussi le mettre à jour), et un plan se lit plutôt qu'il ne se devine : on regarde EXPLAIN, puis on indexe. Les bases de données : modéliser et faire évoluer traite les index à fond.


Le bac à sable

Les trois tables de la boutique, une console, aucune consigne imposée. Six missions se cochent quand ta requête trouve le bon résultat, mais rien ne t'y oblige. Essaie aussi ce qu'on vient de lire : que se passe-t-il si tu écris un alias dans un WHERE, un COUNT(*) dans un WHERE, ou une comparaison avec NULL ?

Figure interactiveConsole SQL : interroge les trois tablesTape une requête, ou choisis-en une dans la liste et modifie-la avant de la lancer. Les missions se cochent seules.
Les tables : clients, commandes, paiements
clients
id │ nom   │ ville    
───┼───────┼──────────
1  │ Ana   │ Paris    
2  │ Bruno │ Lyon     
3  │ Chloé │ NULL     
4  │ David │ Paris    
5  │ Emma  │ Marseille
commandes
id  │ client_id │ montant
────┼───────────┼────────
101 │ 1         │ 30     
102 │ 1         │ 20     
103 │ 2         │ 50     
104 │ 4         │ 15     
105 │ 9         │ 40     
paiements
commande_id │ montant_paye
────────────┼─────────────
101         │ 15          
101         │ 15          
103         │ 50          
  1. À faire : Compte les clients de chaque ville.
  2. À faire : Trouve le client dont la ville est inconnue (pas avec = NULL).
  3. À faire : Trouve la commande dont le client n'existe pas, avec un LEFT JOIN.
  4. À faire : Montre, commande par commande, ce qui a été payé : la commande 102 doit apparaître.
  5. À faire : Trouve les clients sans aucune commande (LEFT JOIN, puis IS NULL).
  6. À faire : Trouve les clients qui ont plus d'une commande (HAVING).

Moteur SQL jouet qui suit SQLite : SELECT, JOIN et LEFT JOIN, WHERE avec la logique à trois valeurs de NULL, GROUP BY, HAVING, agrégats, ORDER BY, LIMIT. Ses résultats sont identiques à ceux du vrai SQLite sur 55 requêtes de test. Pas de sous-requête, d'UNION, ni de RIGHT et FULL JOIN.

Le moteur de cette console est un jouet, mais ses résultats sont ceux du vrai SQLite sur les 55 requêtes de test. Il suit SQLite, pas PostgreSQL : les NULL passent en tête d'un tri croissant, et un alias passe dans un WHERE. Les erreurs, elles, sont celles qu'on rencontre au quotidien : une table mal nommée, une colonne ambiguë dans une jointure, une colonne absente du GROUP BY, un agrégat dans un WHERE.


Ce qu'on oublie souvent

  • Les injections SQL. On ne construit jamais une requête en collant du texte venu d'un utilisateur : un nom saisi comme ' OR '1'='1 devient alors du SQL. On passe les valeurs en paramètres, et la bibliothèque s'occupe du reste (la documentation de psycopg interdit de fusionner les valeurs avec % ou +). Le côté attaque est dans Sécurité web : authentifier, protéger.
cur.execute("SELECT * FROM clients WHERE nom = %s", (nom,))
  • Les transactions. BEGIN … COMMIT : tout passe, ou rien. Un UPDATE ou un DELETE sans WHERE touche toute la table : on écrit d'abord le SELECT avec la même condition, on regarde les lignes, et on modifie dans une transaction qu'on peut annuler avec ROLLBACK.
  • Les dialectes. SQL est un standard, et chaque base y ajoute ses habitudes. Les NULL d'un tri croissant viennent en premier dans SQLite et en dernier dans PostgreSQL (documentations lues le 21 octobre 2026) ; ORDER BY ville NULLS LAST lève le doute.
  • SELECT *. Utile pour regarder, risqué dans un programme : une colonne ajoutée à la table change le résultat, et la base envoie des colonnes que personne ne lit. En production, on nomme les colonnes.
  • Les dates et les fuseaux. On stocke les instants en UTC, avec leur fuseau, et on ne convertit qu'à l'affichage.
  • Au-delà du SELECT simple. WITH (une requête nommée, réutilisable dans la suivante) et les fonctions de fenêtre (ROW_NUMBER() OVER (…), SUM(…) OVER (…)) calculent sans regrouper ; ils servent dès que les requêtes s'allongent.

Teste-toi

Quelques questions sur les idées qu'on vient de croiser. La raison s'affiche après chaque réponse : c'est elle qui compte.

Figure interactiveTeste-toi : SQLAprès chaque réponse, la raison s'affiche.

Question 1 sur 12

9 bonnes réponses attendues sur 12.

Pourquoi SELECT montant * 2 AS d FROM commandes WHERE d > 50 échoue-t-il dans PostgreSQL ?

Une bonne réponse par question. Ce sont les erreurs qu'on retrouve le plus dans les requêtes de tous les jours.


Le vocabulaire en un coup d'œil

TermeEn une phrase
TableDes lignes qui ont toutes les mêmes colonnes.
Clé primaireLa colonne qui identifie chaque ligne de façon unique.
Clé étrangèreUne colonne qui contient la clé d'une ligne d'une autre table.
Requête déclarativeOn décrit le résultat voulu ; la base choisit la méthode.
AliasLe nom donné à une colonne calculée avec AS ; il n'existe qu'à partir du SELECT.
NULLL'absence de valeur : inconnue, pas zéro ni vide.
Logique à trois valeursVrai, faux ou inconnu ; WHERE ne garde que le vrai.
GROUP BYRéunir les lignes qui ont la même valeur ; les NULL forment un seul groupe.
HAVINGFiltrer les groupes, après les totaux ; WHERE filtre les lignes avant.
AgrégatUne fonction qui résume un groupe : COUNT, SUM, AVG, MIN, MAX.
JointureMettre côte à côte les lignes de deux tables qui se correspondent.
INNER / LEFT / FULL JOINGarder les correspondances seules / toute la gauche / tout.
GranularitéCe que représente une ligne d'une table : une commande, un paiement.
Plan d'exécutionLa méthode que la base choisit, affichée par EXPLAIN.
IndexUne copie rangée d'une colonne qui évite de lire toute la table.
Requête paramétréeLes valeurs passées à part du texte de la requête, contre les injections.

Ce qu'il faut retenir

  • SQL décrit le résultat voulu ; la base choisit comment le calculer.
  • La requête s'écrit SELECT d'abord mais se calcule FROM, WHERE, regroupement, SELECT, tri, LIMIT : un alias n'existe pas encore au WHERE.
  • NULL est inconnu : IS NULL, jamais = NULL, et WHERE écarte l'inconnu ; un seul NULL dans un NOT IN vide le résultat.
  • WHERE filtre les lignes avant le regroupement, HAVING filtre les groupes après : la même condition ne donne pas les mêmes totaux.
  • Un LEFT JOIN garde chaque ligne de gauche au moins une fois, et une condition sur la droite doit être dans le ON.
  • Joindre une table « plusieurs » multiplie les lignes : on agrège d'abord, on joint ensuite.
  • Une fonction appliquée à une colonne indexée désactive l'index ; EXPLAIN dit si la table est lue en entier.
  • On passe toujours les valeurs en paramètres.

Pour aller plus loin

Pages consultées le 21 octobre 2026.

Et maintenant ?