Combien de commandes par client ? Quel chiffre d’affaires par mois ? Quels produits se vendent plus de 100 fois ? Toutes ces questions se résolvent avec GROUP BY et HAVING. Dans ce guide, tu apprends à regrouper et filtrer tes données SQL avec des exemples concrets sur une vraie base de commandes, les pièges à éviter (dont la fameuse erreur 1055 de MySQL) et des exercices corrigés pour t’entraîner.

🐬 MySQL / MariaDB🧮 COUNT, SUM, AVG🔍 WHERE vs HAVING✍️ 6 exercices corrigés⏱️ 20 min de lecture

Quand tu débutes en SQL, SELECT … FROM … WHERE te permet de lire des lignes. Mais très vite, on te demande des chiffres : le total des ventes, le nombre de clients par ville, la moyenne des notes. C’est là qu’interviennent GROUP BY et HAVING, deux clauses qui transforment ta base de données en véritable outil d’analyse.

GROUP BY et HAVING : GROUP BY et HAVING expliqués avec des exemples concrets
GROUP BY et HAVING expliqués avec des exemples concrets

Ce guide part de zéro et va jusqu’aux cas avancés. Chaque notion est illustrée sur la même petite base de données, avec le résultat obtenu, pour que tu voies exactement ce que fait chaque requête.

GROUP BY et HAVING : l’ordre d’exécution d’une requête
Le principe de GROUP BY : regrouper les lignes, calculer un résumé par groupe, puis filtrer avec HAVING.
🎯
Ce que tu vas apprendre
  • Les fonctions d’agrégation : COUNT, SUM, AVG, MIN, MAX.
  • Regrouper avec GROUP BY, sur une ou plusieurs colonnes.
  • La différence fondamentale entre WHERE et HAVING.
  • L’ordre d’exécution réel d’une requête SQL.
  • Corriger l’erreur MySQL 1055 (ONLY_FULL_GROUP_BY).
  • Les techniques avancées : WITH ROLLUP, GROUP_CONCAT, agrégations conditionnelles.

La base de données d’exemple

On travaille sur une boutique en ligne avec deux tables. Copie ce script dans ton outil SQL (phpMyAdmin, MySQL Workbench, DBeaver…) pour suivre les exemples :

CREATE TABLE clients (
    id INT PRIMARY KEY,
    nom VARCHAR(50) NOT NULL,
    ville VARCHAR(50) NOT NULL
);

CREATE TABLE commandes (
    id INT PRIMARY KEY,
    client_id INT NOT NULL,
    date_commande DATE NOT NULL,
    montant DECIMAL(8,2) NOT NULL,
    statut VARCHAR(20) NOT NULL,
    FOREIGN KEY (client_id) REFERENCES clients(id)
);

INSERT INTO clients VALUES
(1, 'Alice', 'Bruxelles'), (2, 'Bruno', 'Liège'),
(3, 'Chloé', 'Bruxelles'), (4, 'David', 'Namur');

INSERT INTO commandes VALUES
(1, 1, '2026-01-05', 120.00, 'livrée'),
(2, 1, '2026-01-20',  45.50, 'livrée'),
(3, 2, '2026-01-22', 300.00, 'livrée'),
(4, 3, '2026-02-02',  80.00, 'annulée'),
(5, 1, '2026-02-14', 210.00, 'livrée'),
(6, 2, '2026-02-18',  60.00, 'en cours'),
(7, 3, '2026-03-01', 150.00, 'livrée'),
(8, 2, '2026-03-03',  95.00, 'livrée');
SQL

David (client n°4) n’a passé aucune commande : on s’en servira pour un piège classique.

Les fonctions d’agrégation

Une fonction d’agrégation prend un ensemble de lignes et renvoie une seule valeur. Sans GROUP BY, elle s’applique à toute la table :

SELECT
    COUNT(*)      AS nb_commandes,
    SUM(montant)  AS total,
    AVG(montant)  AS panier_moyen,
    MIN(montant)  AS plus_petite,
    MAX(montant)  AS plus_grosse
FROM commandes;
SQL
nb_commandestotalpanier_moyenplus_petiteplus_grosse
81060.50132.56250045.50300.00
FonctionCe qu’elle calculeIgnore les NULL ?
COUNT(*)Le nombre de lignesNon (compte toutes les lignes)
COUNT(colonne)Le nombre de valeurs non nullesOui
COUNT(DISTINCT colonne)Le nombre de valeurs différentesOui
SUM(colonne)La sommeOui
AVG(colonne)La moyenneOui
MIN(colonne) / MAX(colonne)La plus petite / plus grande valeur (aussi pour les dates et les textes)Oui
💡
AVG et les valeurs NULL

AVG ignore les NULL : la moyenne de 10, 20 et NULL est 15, pas 10. Si une valeur absente doit compter comme zéro, écris AVG(COALESCE(note, 0)).

GROUP BY : un résultat par groupe

GROUP BY découpe les lignes en groupes qui partagent la même valeur, puis applique les fonctions d’agrégation à chaque groupe. Le résultat contient une ligne par groupe.

Combien de commandes et quel total par client ?

SELECT client_id,
       COUNT(*)     AS nb_commandes,
       SUM(montant) AS total
FROM commandes
GROUP BY client_id;
SQL
client_idnb_commandestotal
13375.50
23455.00
32230.00

Ce qui se passe en coulisses :

  1. MySQL rassemble les lignes du client 1 (commandes 1, 2, 5), celles du client 2 (3, 6, 8) et celles du client 3 (4, 7) ;
  2. pour chaque groupe, il calcule COUNT(*) et SUM(montant) ;
  3. il renvoie une ligne par groupe.

Afficher le nom du client avec une jointure

Un identifiant n’est pas très parlant. On ajoute le nom grâce à une jointure avec la table clients :

SELECT c.nom,
       COUNT(*)       AS nb_commandes,
       SUM(m.montant) AS total
FROM commandes AS m
JOIN clients   AS c ON c.id = m.client_id
GROUP BY c.id, c.nom
ORDER BY total DESC;
SQL
nomnb_commandestotal
Bruno3455.00
Alice3375.50
Chloé2230.00
✅
Regroupe par l’identifiant, pas seulement par le nom

Deux clients peuvent s’appeler « Alice ». En regroupant par c.nom seul, leurs commandes seraient fusionnées. Regroupe toujours par la clé primaire (c.id) et ajoute les colonnes à afficher.

Regrouper sur plusieurs colonnes

Chaque combinaison de valeurs forme un groupe. Nombre de commandes par client et par statut :

SELECT client_id, statut, COUNT(*) AS nb
FROM commandes
GROUP BY client_id, statut
ORDER BY client_id, statut;
SQL
client_idstatutnb
1livrée3
2en cours1
2livrée2
3annulée1
3livrée1

Regrouper par période

Pour un chiffre d’affaires mensuel, on regroupe sur une expression calculée à partir de la date :

SELECT DATE_FORMAT(date_commande, '%Y-%m') AS mois,
       COUNT(*)                            AS nb_commandes,
       SUM(montant)                        AS chiffre_affaires
FROM commandes
WHERE statut = 'livrée'
GROUP BY mois
ORDER BY mois;
SQL
moisnb_commandeschiffre_affaires
2026-013465.50
2026-021210.00
2026-032245.00
💡
Utiliser un alias dans GROUP BY

MySQL et MariaDB acceptent l’alias mois dans GROUP BY. Ce n’est pas le cas de tous les systèmes : sous SQL Server, il faut répéter l’expression DATE_FORMAT(...). PostgreSQL l’accepte aussi.

HAVING : filtrer les groupes

Et si on ne veut que les clients qui ont dépensé plus de 300 € ? Premier réflexe : utiliser WHERE…

-- ❌ Erreur : on ne peut pas utiliser une fonction d'agrégation dans WHERE
SELECT client_id, SUM(montant) AS total
FROM commandes
WHERE SUM(montant) > 300
GROUP BY client_id;
-- ERROR 1111 (HY000): Invalid use of group function
SQL

WHERE filtre les lignes, avant que les groupes n’existent : à ce moment-là, la somme n’a pas encore été calculée. Pour filtrer les groupes, après le calcul, on utilise HAVING :

-- ✅ HAVING filtre les groupes après l'agrégation
SELECT client_id, SUM(montant) AS total
FROM commandes
GROUP BY client_id
HAVING SUM(montant) > 300;
SQL
client_idtotal
1375.50
2455.00

WHERE ou HAVING : la différence en un tableau

WHEREHAVING
Filtre…les lignesles groupes
S’exécute…avant le regroupementaprès le regroupement
Fonctions d’agrégationInterditesAutorisées
ExempleWHERE statut = 'livrée'HAVING COUNT(*) >= 3

Les deux se combinent très bien. Les clients qui ont au moins deux commandes livrées :

SELECT client_id, COUNT(*) AS nb_livrees
FROM commandes
WHERE statut = 'livrée'      -- 1. on ne garde que les commandes livrées
GROUP BY client_id           -- 2. on regroupe par client
HAVING COUNT(*) >= 2;        -- 3. on garde les groupes d'au moins 2 commandes
SQL
client_idnb_livrees
13
22
⚡
Filtre le plus tôt possible

Une condition qui porte sur une colonne (et pas sur un agrégat) doit aller dans WHERE, pas dans HAVING. WHERE réduit le nombre de lignes avant le regroupement, ce qui est beaucoup plus rapide, et peut utiliser les index. HAVING statut = 'livrée' fonctionnerait parfois, mais regrouperait inutilement toutes les lignes avant de les filtrer.

L’ordre d’exécution d’une requête SQL

C’est la clé pour comprendre GROUP BY et HAVING. Tu écris la requête dans un ordre, mais le moteur l’exécute dans un autre :

Ordre d’écritureOrdre d’exécutionÉtape
1. SELECT1. FROM + JOINRécupère et assemble les tables
2. FROM2. WHEREFiltre les lignes
3. WHERE3. GROUP BYForme les groupes
4. GROUP BY4. HAVINGFiltre les groupes
5. HAVING5. SELECTCalcule les colonnes à afficher
6. ORDER BY6. ORDER BYTrie le résultat
7. LIMIT7. LIMITGarde les N premières lignes

Cet ordre explique tout : pourquoi WHERE ne connaît pas les agrégats (ils n’existent pas encore), et pourquoi les alias du SELECT ne sont, en SQL standard, pas utilisables dans WHERE.

L’erreur MySQL 1055 et ONLY_FULL_GROUP_BY

Tôt ou tard, tu rencontreras cette erreur :

ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause
and contains nonaggregated column 'boutique.commandes.date_commande' which is
not functionally dependent on columns in GROUP BY clause; this is incompatible
with sql_mode=only_full_group_by
Texte

Elle vient d’une requête comme celle-ci :

-- ❌ Quelle date afficher ? Le client 1 a TROIS dates différentes
SELECT client_id, date_commande, SUM(montant)
FROM commandes
GROUP BY client_id;
SQL

Chaque groupe contient plusieurs lignes, donc plusieurs dates. MySQL ne peut pas savoir laquelle tu veux. Avec le mode ONLY_FULL_GROUP_BY (activé par défaut depuis MySQL 5.7), il refuse la requête plutôt que de choisir une date au hasard.

La règle : chaque colonne du SELECT doit être soit dans le GROUP BY, soit dans une fonction d’agrégation. Corrige en précisant ce que tu veux vraiment :

-- ✅ La date de la dernière commande de chaque client
SELECT client_id,
       MAX(date_commande) AS derniere_commande,
       SUM(montant)       AS total
FROM commandes
GROUP BY client_id;
SQL
🚫
Ne désactive pas ONLY_FULL_GROUP_BY

Beaucoup de tutoriels conseillent de retirer ONLY_FULL_GROUP_BY du sql_mode. L’erreur disparaît, mais tes requêtes renvoient alors des valeurs arbitraires et tes résultats peuvent être faux sans que tu t’en rendes compte. Corrige la requête : c’est elle qui est ambiguë.

Les pièges classiques de GROUP BY et HAVING

COUNT(\*) et les jointures externes

On veut le nombre de commandes de tous les clients, y compris ceux qui n’ont rien commandé :

SELECT c.nom, COUNT(*) AS nb_commandes   -- ❌ David apparaît avec 1 commande !
FROM clients AS c
LEFT JOIN commandes AS m ON m.client_id = c.id
GROUP BY c.id, c.nom;
SQL

Avec LEFT JOIN, David a une ligne (avec des NULL à la place de la commande), et COUNT(*) compte les lignes. La correction : compter une colonne de la table de droite, qui vaut NULL pour David.

SELECT c.nom, COUNT(m.id) AS nb_commandes   -- ✅ David : 0
FROM clients AS c
LEFT JOIN commandes AS m ON m.client_id = c.id
GROUP BY c.id, c.nom;
SQL
nomnb_commandes
Alice3
Bruno3
Chloé2
David0

SUM qui renvoie NULL au lieu de 0

Pour David, SUM(m.montant) renvoie NULL (aucune valeur à additionner). Pour afficher 0 :

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

Les valeurs NULL forment un groupe

Si une colonne de regroupement contient des NULL, toutes ces lignes sont rassemblées dans un même groupe NULL. Pense à le traiter, par exemple avec COALESCE(ville, 'Non renseignée').

Techniques avancées

Des agrégations conditionnelles (tableau croisé)

Combiner SUM et CASE permet de compter plusieurs catégories sur une seule ligne :

SELECT client_id,
       SUM(CASE WHEN statut = 'livrée'   THEN 1 ELSE 0 END) AS livrees,
       SUM(CASE WHEN statut = 'en cours' THEN 1 ELSE 0 END) AS en_cours,
       SUM(CASE WHEN statut = 'annulée'  THEN 1 ELSE 0 END) AS annulees
FROM commandes
GROUP BY client_id;
SQL
client_idlivreesen_coursannulees
1300
2210
3101

GROUP_CONCAT : lister les valeurs d’un groupe

SELECT c.ville,
       COUNT(*)                                       AS nb_clients,
       GROUP_CONCAT(c.nom ORDER BY c.nom SEPARATOR ', ') AS clients
FROM clients AS c
GROUP BY c.ville;
SQL
villenb_clientsclients
Bruxelles2Alice, Chloé
Liège1Bruno
Namur1David

GROUP_CONCAT est propre à MySQL et MariaDB. Son équivalent est STRING_AGG sous PostgreSQL et SQL Server.

WITH ROLLUP : les sous-totaux et le total général

SELECT statut, COUNT(*) AS nb, SUM(montant) AS total
FROM commandes
GROUP BY statut WITH ROLLUP;
SQL
statutnbtotal
annulée180.00
en cours160.00
livrée6920.50
NULL81060.50

La dernière ligne, avec NULL, est le total général. Affiche-la plus joliment avec COALESCE(statut, 'TOTAL').

Exercices corrigés

Entraîne-toi sur la base d’exemple. Cherche d’abord par toi-même, puis compare avec la solution.

Exercice 1 : le panier moyen de chaque client, arrondi à 2 décimales.

SELECT client_id, ROUND(AVG(montant), 2) AS panier_moyen
FROM commandes
GROUP BY client_id;
SQL

Exercice 2 : le nombre de clients par ville, de la plus peuplée à la moins peuplée.

SELECT ville, COUNT(*) AS nb_clients
FROM clients
GROUP BY ville
ORDER BY nb_clients DESC;
SQL

Exercice 3 : les mois où le chiffre d’affaires des commandes livrées dépasse 200 €.

SELECT DATE_FORMAT(date_commande, '%Y-%m') AS mois, SUM(montant) AS ca
FROM commandes
WHERE statut = 'livrée'
GROUP BY mois
HAVING SUM(montant) > 200;
SQL

Exercice 4 : les clients qui ont au moins une commande annulée.

SELECT client_id
FROM commandes
GROUP BY client_id
HAVING SUM(statut = 'annulée') > 0;   -- en MySQL, une condition vaut 1 ou 0
SQL

Exercice 5 : le chiffre d’affaires livré par ville, villes sans commande comprises.

SELECT c.ville, COALESCE(SUM(m.montant), 0) AS ca
FROM clients AS c
LEFT JOIN commandes AS m
       ON m.client_id = c.id AND m.statut = 'livrée'
GROUP BY c.ville
ORDER BY ca DESC;
SQL

Remarque : la condition sur le statut est placée dans le ON et non dans le WHERE. Dans le WHERE, elle éliminerait les clients sans commande (leur statut vaut NULL), et Namur disparaîtrait du résultat.

Exercice 6 : le meilleur client (le total le plus élevé).

SELECT client_id, SUM(montant) AS total
FROM commandes
GROUP BY client_id
ORDER BY total DESC
LIMIT 1;
SQL
GROUP BY et HAVING : les points essentiels à retenir
Les points essentiels à retenir de ce guide.

FAQ

Peut-on utiliser HAVING sans GROUP BY ?

Oui : toute la table forme alors un seul groupe. SELECT COUNT(*) FROM commandes HAVING COUNT(*) > 5 renvoie le nombre de commandes uniquement s’il dépasse 5. C’est rare en pratique.

Quelle différence entre GROUP BY et DISTINCT ?

SELECT DISTINCT ville FROM clients et SELECT ville FROM clients GROUP BY ville donnent le même résultat. Mais seul GROUP BY permet de calculer des agrégats par groupe (COUNT, SUM…). Utilise DISTINCT pour dédoublonner, GROUP BY pour résumer.

GROUP BY trie-t-il les résultats ?

Ne compte pas dessus. Les anciennes versions de MySQL triaient implicitement, ce n’est plus le cas depuis MySQL 8.0. Ajoute toujours un ORDER BY si l’ordre compte.

Comment accélérer une requête GROUP BY lente ?

Filtre au maximum dans WHERE, crée un index sur les colonnes du GROUP BY (et du WHERE), et ne sélectionne que les colonnes nécessaires. La commande EXPLAIN te montre si l’index est utilisé.

Conclusion

Avec GROUP BY et HAVING, tu passes de la simple lecture de données à leur analyse : GROUP BY forme les groupes, les fonctions d’agrégation les résument, WHERE filtre les lignes avant et HAVING filtre les groupes après. Retiens l’ordre d’exécution (FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY), la règle de l’erreur 1055 et le piège du COUNT(*) avec LEFT JOIN, et tu éviteras 90 % des erreurs.

Pour pratiquer sur un projet complet, notre tutoriel SQL : créer la base de données d’une bibliothèque te propose un autre jeu de données sur lequel appliquer ces requêtes. La documentation MySQL sur les fonctions d’agrégation détaille toutes les fonctions disponibles.

Comments

No comments yet. Why don’t you start the discussion?

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *