Chapitre 717 minDébutant
COUNT, GROUP BY et HAVING
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- compter et totaliser avec
COUNT,SUMetAVG; - regrouper les lignes avec
GROUP BY; - filtrer les groupes avec
HAVING, et savoir quand garderWHERE; - éviter les pièges des
NULLet de la moyenne des moyennes.
5 exercices corrigés1 schéma
Une fonction d'agrégation (COUNT, SUM, AVG, MIN, MAX) résume plusieurs lignes en une seule valeur : un nombre, une somme, une moyenne. GROUP BY calcule ces agrégats par groupe de lignes, et HAVING filtre les groupes obtenus. Dans une requête SELECT simple, chaque ligne du résultat correspond à une ligne de la table ; avec un agrégat, une ligne du résultat en résume plusieurs. Les exemples portent sur la base d'exemple parc.
COUNT sous toutes ses formes
| Écriture | Compte |
|---|---|
COUNT(*) | Les lignes |
COUNT(colonne) | Les valeurs non NULL de la colonne |
COUNT(DISTINCT colonne) | Les valeurs différentes, non NULL |
SELECT COUNT(*) AS employes,
COUNT(telephone) AS avec_telephone,
COUNT(DISTINCT id_service) AS services_representes
FROM employe;
| employes | avec_telephone | services_representes |
|---|---|---|
| 14 | 11 | 5 |
Trois employés n'ont pas de téléphone, et le service Juridique n'a personne : 5 services sur 6 sont représentés.
SUM, AVG, MIN, MAX et les NULL
SUM, AVG, MIN et MAX ignorent les NULL. Sur la table logiciel, quatre logiciels ont un nombre de licences, quatre sont libres (NULL) :
SELECT SUM(nb_licences) AS total,
AVG(nb_licences) AS moyenne_sans_null,
AVG(COALESCE(nb_licences, 0)) AS moyenne_null_a_zero
FROM logiciel;
| total | moyenne_sans_null | moyenne_null_a_zero |
|---|---|---|
| 25 | 6.2500 | 3.1250 |
La première moyenne divise 25 par 4, la seconde par 8. Laquelle est juste ? Celle qui répond à ta question : « nombre moyen de licences des logiciels payants » ou « par logiciel ». Sur une colonne entière, MySQL renvoie la moyenne avec 4 décimales ; utilise ROUND pour l'affichage.
Sur un ensemble vide, COUNT renvoie 0 mais SUM, AVG, MIN et MAX renvoient NULL :
SELECT COUNT(*) AS nb, SUM(duree_min) AS total
FROM intervention
WHERE id_ticket = 7;
| nb | total |
|---|---|
| 0 | NULL |
Écris COALESCE(SUM(duree_min), 0) si tu veux afficher 0.
MIN et MAX fonctionnent aussi sur les dates et les chaînes : MIN(date_achat) donne la date d'achat la plus ancienne, MAX(nom) le dernier nom dans l'ordre alphabétique. MIN renvoie une valeur, pas la ligne qui la porte : pour savoir quel poste a été acheté à cette date, il faut une sous-requête.
GROUP BY
GROUP BY forme des groupes de lignes qui ont la même valeur, puis calcule les agrégats par groupe. Le résultat contient une ligne par groupe.
SELECT id_technicien,
COUNT(*) AS nb_interventions,
SUM(duree_min) AS minutes,
ROUND(AVG(duree_min), 1) AS duree_moyenne
FROM intervention
GROUP BY id_technicien
ORDER BY id_technicien;
| id_technicien | nb_interventions | minutes | duree_moyenne |
|---|---|---|---|
| 10 | 4 | 510 | 127.5 |
| 11 | 6 | 295 | 49.2 |
| 12 | 7 | 450 | 64.3 |
On peut regrouper sur plusieurs colonnes : chaque combinaison forme un groupe.
SELECT statut, priorite, COUNT(*) AS nb
FROM ticket
GROUP BY statut, priorite
ORDER BY statut, priorite;
| statut | priorite | nb |
|---|---|---|
| en_cours | basse | 1 |
| en_cours | critique | 1 |
| en_cours | haute | 1 |
| ouvert | basse | 1 |
| ouvert | normale | 2 |
| resolu | basse | 1 |
| resolu | critique | 1 |
| resolu | haute | 3 |
| resolu | normale | 3 |
La règle d'or : dans une requête avec GROUP BY, chaque colonne du SELECT est soit dans le GROUP BY, soit à l'intérieur d'une fonction d'agrégation. Sinon, quelle valeur afficher pour un groupe de plusieurs lignes ?
SELECT id_technicien, id_ticket, COUNT(*)
FROM intervention
GROUP BY id_technicien;
ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column
'parc.intervention.id_ticket' which is not functionally dependent on columns in GROUP BY clause;
this is incompatible with sql_mode=only_full_group_by
Le technicien 11 a travaillé sur 5 tickets différents : id_ticket n'a pas de valeur unique dans son groupe. Le mode ONLY_FULL_GROUP_BY, actif par défaut depuis MySQL 5.7, refuse la requête. Sans ce mode (vieux serveurs, MariaDB par défaut), MySQL renvoie une valeur prise au hasard dans le groupe, sans erreur.
Le message parle de dépendance fonctionnelle : MySQL 8 accepte une colonne absente du GROUP BY si elle dépend de la clé primaire d'une table regroupée. GROUP BY s.id_service permet donc d'afficher s.libelle, puisqu'un identifiant de service détermine un seul libellé. PostgreSQL accepte la même chose ; SQL Server exige toutes les colonnes dans le GROUP BY.
Différence entre WHERE et HAVING
WHERE filtre les lignes, avant le regroupement. HAVING filtre les groupes, après le calcul des agrégats. Les techniciens qui ont fait plus de 5 interventions :
SELECT id_technicien, COUNT(*) AS nb_interventions
FROM intervention
GROUP BY id_technicien
HAVING COUNT(*) > 5;
| id_technicien | nb_interventions |
|---|---|
| 11 | 6 |
| 12 | 7 |
Les deux se combinent. Pour le mois d'octobre seulement (WHERE), les techniciens qui cumulent plus de 250 minutes (HAVING) :
SELECT id_technicien, SUM(duree_min) AS minutes_octobre
FROM intervention
WHERE date_intervention >= '2026-10-01' AND date_intervention < '2026-11-01'
GROUP BY id_technicien
HAVING SUM(duree_min) > 250
ORDER BY minutes_octobre DESC;
| id_technicien | minutes_octobre |
|---|---|
| 10 | 330 |
| 12 | 275 |
Chloé Lambert (11) a travaillé 220 minutes en octobre : son groupe existe, mais HAVING l'élimine. MySQL accepte aussi un alias du SELECT dans HAVING (HAVING minutes_octobre > 250) ; c'est une extension, que PostgreSQL et SQL Server refusent. Répéter l'agrégat, comme ici, fonctionne partout.
GROUP_CONCAT et WITH ROLLUP
GROUP_CONCAT assemble les valeurs d'un groupe en une seule chaîne :
SELECT id_service,
COUNT(*) AS effectif,
GROUP_CONCAT(nom ORDER BY nom SEPARATOR ', ') AS membres
FROM employe
GROUP BY id_service
ORDER BY id_service;
| id_service | effectif | membres |
|---|---|---|
| 1 | 1 | Garnier |
| 2 | 3 | Lefèvre, Martin, Morel |
| 3 | 3 | Benali, Petit, Roux |
| 4 | 4 | Bonnet, Faure, Girard, Rousseau |
| 5 | 3 | Diallo, Lambert, Mercier |
Sans SEPARATOR, le séparateur est une virgule sans espace. Le résultat est coupé à 1 024 octets par défaut (variable group_concat_max_len), sans erreur : augmente-la pour de longues listes. PostgreSQL et SQL Server écrivent string_agg(nom, ', ').
WITH ROLLUP ajoute des lignes de sous-total et de total général. La colonne regroupée y vaut NULL ; la fonction GROUPING() vaut 1 sur ces lignes, ce qui permet de les étiqueter :
SELECT IF(GROUPING(id_technicien), 'Total', id_technicien) AS technicien,
SUM(duree_min) AS minutes
FROM intervention
GROUP BY id_technicien WITH ROLLUP;
| technicien | minutes |
|---|---|
| 10 | 510 |
| 11 | 295 |
| 12 | 450 |
| Total | 1255 |
PostgreSQL et SQL Server écrivent GROUP BY ROLLUP (id_technicien). MariaDB accepte WITH ROLLUP mais ne propose pas GROUPING() : tant que la colonne regroupée ne contient pas de NULL, IFNULL(id_technicien, 'Total') fait l'affaire.
Agrégats conditionnels
Un CASE à l'intérieur d'un agrégat compte ou additionne seulement certaines lignes. C'est la technique du tableau croisé : une ligne par technicien, une colonne par mois.
SELECT id_technicien,
SUM(CASE WHEN MONTH(date_intervention) = 9 THEN 1 ELSE 0 END) AS septembre,
SUM(CASE WHEN MONTH(date_intervention) = 10 THEN 1 ELSE 0 END) AS octobre,
COUNT(*) AS total
FROM intervention
GROUP BY id_technicien
ORDER BY id_technicien;
| id_technicien | septembre | octobre | total |
|---|---|---|---|
| 10 | 1 | 2 | 4 |
| 11 | 3 | 3 | 6 |
| 12 | 4 | 3 | 7 |
Le total d'Antoine Mercier (10) dépasse la somme des deux mois : son intervention du 31 août n'est dans aucune colonne. COUNT(CASE WHEN ... THEN 1 END) donne le même résultat, puisque COUNT ignore les NULL produits quand la condition est fausse. MySQL accepte aussi SUM(MONTH(date_intervention) = 9), une comparaison valant 1 ou 0, mais cette écriture n'est pas portable.
Le piège de la moyenne des moyennes
La durée moyenne d'une intervention est de 73,8 minutes (1 255 minutes pour 17 interventions). La moyenne des trois durées moyennes par technicien, elle, vaut 80,3 : (127,5 + 49,2 + 64,3) / 3. Ce n'est pas une erreur de calcul, c'est une autre question. La moyenne des moyennes donne le même poids à chaque technicien, qu'il ait fait 4 ou 7 interventions. Antoine Mercier, peu d'interventions mais longues, pèse autant que Moussa Diallo.
SELECT ROUND(AVG(duree_min), 1) AS moyenne_globale FROM intervention;
| moyenne_globale |
|---|
| 73.8 |
Exercices
Exercice 1 sur 5 Valeur du parc par marque Réussi À revoir
Pour chaque marque, affiche le nombre de postes, la valeur d'achat totale et le prix moyen arrondi à l'euro. Trie par valeur totale décroissante.
Voir la réponseMasquer la réponse de l'exercice 1
SELECT marque,
COUNT(*) AS nb_postes,
SUM(prix_achat) AS valeur_totale,
ROUND(AVG(prix_achat)) AS prix_moyen
FROM poste
GROUP BY marque
ORDER BY valeur_totale DESC;
| marque | nb_postes | valeur_totale | prix_moyen |
|---|---|---|---|
| Lenovo | 4 | 4836.00 | 1209 |
| HP | 6 | 4414.00 | 736 |
| Dell | 5 | 4265.00 | 853 |
Tu avais trouvé ?
Exercice 2 sur 5 Les gros tickets Réussi À revoir
Pour chaque ticket, affiche le nombre d'interventions et la durée totale, seulement pour les tickets qui ont demandé plus d'une heure de travail au total. Trie par durée décroissante.
Voir la réponseMasquer la réponse de l'exercice 2
SELECT id_ticket, COUNT(*) AS nb_interventions, SUM(duree_min) AS minutes
FROM intervention
GROUP BY id_ticket
HAVING SUM(duree_min) > 60
ORDER BY minutes DESC;
| id_ticket | nb_interventions | minutes |
|---|---|---|
| 9 | 3 | 570 |
| 5 | 2 | 210 |
| 13 | 2 | 150 |
| 2 | 2 | 70 |
| 1 | 2 | 65 |
Le ticket 4 (exactement 60 minutes) n'apparaît pas : la condition est « plus d'une heure », donc > 60.
Tu avais trouvé ?
Exercice 3 sur 5 Les services les plus étoffés Réussi À revoir
Affiche l'identifiant des services qui comptent au moins 3 employés, avec leur effectif. Puis ajoute une condition : ne compter que les employés embauchés avant 2024. Quelles lignes disparaissent ?
Voir la réponseMasquer la réponse de l'exercice 3
SELECT id_service, COUNT(*) AS effectif
FROM employe
GROUP BY id_service
HAVING COUNT(*) >= 3
ORDER BY id_service;
| id_service | effectif |
|---|---|
| 2 | 3 |
| 3 | 3 |
| 4 | 4 |
| 5 | 3 |
SELECT id_service, COUNT(*) AS effectif
FROM employe
WHERE date_embauche < '2024-01-01'
GROUP BY id_service
HAVING COUNT(*) >= 3
ORDER BY id_service;
| id_service | effectif |
|---|---|
| 4 | 3 |
| 5 | 3 |
Le WHERE retire Paul Martin (2026), Thomas Petit et Léa Rousseau (2024) avant le regroupement : la comptabilité et le commercial tombent à 2 employés, et HAVING les élimine.
Tu avais trouvé ?
Exercice 4 sur 5 Tableau de bord des tickets Réussi À revoir
En une seule ligne, affiche le nombre total de tickets et le nombre de tickets par priorité, une colonne par priorité (basse, normale, haute, critique).
Voir la réponseMasquer la réponse de l'exercice 4
SELECT COUNT(*) AS total,
SUM(CASE WHEN priorite = 'basse' THEN 1 ELSE 0 END) AS basse,
SUM(CASE WHEN priorite = 'normale' THEN 1 ELSE 0 END) AS normale,
SUM(CASE WHEN priorite = 'haute' THEN 1 ELSE 0 END) AS haute,
SUM(CASE WHEN priorite = 'critique' THEN 1 ELSE 0 END) AS critique
FROM ticket;
| total | basse | normale | haute | critique |
|---|---|---|---|---|
| 14 | 3 | 5 | 4 | 2 |
Sans GROUP BY, toute la table forme un seul groupe : le résultat a une ligne.
Tu avais trouvé ?
Exercice 5 sur 5 Trouve l'erreur Réussi À revoir
Cette requête doit afficher, pour chaque technicien, la date de sa dernière intervention et le ticket concerné. Pourquoi est-elle refusée par MySQL 8, et pourquoi ne peut-on pas la corriger en ajoutant simplement id_ticket au GROUP BY ?
SELECT id_technicien, MAX(date_intervention) AS derniere, id_ticket
FROM intervention
GROUP BY id_technicien;
Voir la réponseMasquer la réponse de l'exercice 5
id_ticket n'est ni regroupé ni agrégé : erreur 1055. L'ajouter au GROUP BY change la question : on obtiendrait la dernière intervention de chaque couple (technicien, ticket), soit une ligne par ticket traité.
MAX donne la date, pas la ligne qui la porte. Pour retrouver le ticket correspondant, passe par une sous-requête corrélée ou une fonction de fenêtrage :
SELECT i.id_technicien, i.date_intervention AS derniere, i.id_ticket
FROM intervention i
WHERE i.date_intervention = (SELECT MAX(i2.date_intervention)
FROM intervention i2
WHERE i2.id_technicien = i.id_technicien)
ORDER BY i.id_technicien;
| id_technicien | derniere | id_ticket |
|---|---|---|
| 10 | 2026-10-26 08:00:00 | 13 |
| 11 | 2026-10-12 09:05:00 | 10 |
| 12 | 2026-10-27 08:30:00 | 13 |
Tu avais trouvé ?