Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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, SUM et AVG ;
  • regrouper les lignes avec GROUP BY ;
  • filtrer les groupes avec HAVING, et savoir quand garder WHERE ;
  • éviter les pièges des NULL et 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

ÉcritureCompte
COUNT(*)Les lignes
COUNT(colonne)Les valeurs non NULL de la colonne
COUNT(DISTINCT colonne)Les valeurs différentes, non NULL
sql
SELECT COUNT(*)                   AS employes,
       COUNT(telephone)           AS avec_telephone,
       COUNT(DISTINCT id_service) AS services_representes
FROM employe;
employesavec_telephoneservices_representes
14115

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) :

sql
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;
totalmoyenne_sans_nullmoyenne_null_a_zero
256.25003.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 :

sql
SELECT COUNT(*) AS nb, SUM(duree_min) AS total
FROM intervention
WHERE id_ticket = 7;
nbtotal
0NULL

É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.

sql
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_techniciennb_interventionsminutesduree_moyenne
104510127.5
11629549.2
12745064.3
1. Les lignes2. GROUP BY id_technicien3. Le résultatid_technicienduree_min114511201230124011101060129010120121511151024012180111501155123510901260technicien 104 lignes60 120 240 90technicien 116 lignes45 20 10 15 150 55technicien 127 lignes30 40 90 15 180 35 60id_techniciennb_interventionsminutes104510116295127450
GROUP BY range les lignes en groupes, puis chaque groupe devient une ligne

On peut regrouper sur plusieurs colonnes : chaque combinaison forme un groupe.

sql
SELECT statut, priorite, COUNT(*) AS nb
FROM ticket
GROUP BY statut, priorite
ORDER BY statut, priorite;
statutprioritenb
en_coursbasse1
en_courscritique1
en_courshaute1
ouvertbasse1
ouvertnormale2
resolubasse1
resolucritique1
resoluhaute3
resolunormale3

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 ?

sql
SELECT id_technicien, id_ticket, COUNT(*)
FROM intervention
GROUP BY id_technicien;
text
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 :

sql
SELECT id_technicien, COUNT(*) AS nb_interventions
FROM intervention
GROUP BY id_technicien
HAVING COUNT(*) > 5;
id_techniciennb_interventions
116
127

Les deux se combinent. Pour le mois d'octobre seulement (WHERE), les techniciens qui cumulent plus de 250 minutes (HAVING) :

sql
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_technicienminutes_octobre
10330
12275

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 :

sql
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_serviceeffectifmembres
11Garnier
23Lefèvre, Martin, Morel
33Benali, Petit, Roux
44Bonnet, Faure, Girard, Rousseau
53Diallo, 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 :

sql
SELECT IF(GROUPING(id_technicien), 'Total', id_technicien) AS technicien,
       SUM(duree_min) AS minutes
FROM intervention
GROUP BY id_technicien WITH ROLLUP;
technicienminutes
10510
11295
12450
Total1255

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.

sql
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_technicienseptembreoctobretotal
10124
11336
12437

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.

sql
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
sql
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;
marquenb_postesvaleur_totaleprix_moyen
Lenovo44836.001209
HP64414.00736
Dell54265.00853

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
sql
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_ticketnb_interventionsminutes
93570
52210
132150
2270
1265

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
sql
SELECT id_service, COUNT(*) AS effectif
FROM employe
GROUP BY id_service
HAVING COUNT(*) >= 3
ORDER BY id_service;
id_serviceeffectif
23
33
44
53
sql
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_serviceeffectif
43
53

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
sql
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;
totalbassenormalehautecritique
143542

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 ?

sql
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 :

sql
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_technicienderniereid_ticket
102026-10-26 08:00:0013
112026-10-12 09:05:0010
122026-10-27 08:30:0013

Tu avais trouvé ?

Chapitre terminé !

Fais les 5 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Jointures SQL : INNER JOIN et LEFT JOINRevoir le sommaire du cours