GROUP BY et HAVING sont les deux clauses SQL que chaque candidat doit maîtriser sur le bout des doigts. Ce sont les premières questions posées en entretien technique, et pourtant une majorité de candidats butent sur les subtilités — agrégation partielle, différence entre WHERE et HAVING, ou encore GROUP BY avec plusieurs colonnes.
Ce guide complet, rédigé par Thomas Leroy, couvre tout ce que vous devez savoir : de la syntaxe de base aux cas avancés, avec 10 exercices corrigés pour valider vos acquis.
📌 Ce qu'il faut retenir
- WHERE filtre les lignes, HAVING filtre les groupes — c'est la question la plus posée
- Chaque colonne dans SELECT doit être dans GROUP BY ou dans une agrégation
- COUNT(*) vs COUNT(col) vs COUNT(DISTINCT col) : trois comportements différents
- GROUP BY sur plusieurs colonnes crée des sous-groupes
- Le pivot avec CASE WHEN + SUM est un pattern fondamental
- L'ordre d'exécution SQL (FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY) explique tout
Les fondamentaux de GROUP BY
Qu'est-ce que GROUP BY ?
GROUP BY regroupe les lignes qui partagent les mêmes valeurs dans une ou plusieurs colonnes, puis applique une fonction d'agrégation (SUM, COUNT, AVG, etc.) à chaque groupe.
Prenons une table commandes :
| id | client | produit | montant | date_commande |
|---|---|---|---|---|
| 1 | Alice | Widget A | 150 | 2026-01-05 |
| 2 | Alice | Widget B | 200 | 2026-01-12 |
| 3 | Bob | Widget A | 300 | 2026-01-08 |
| 4 | Bob | Widget A | 250 | 2026-01-20 |
| 5 | Claire | Widget B | 175 | 2026-01-15 |
| 6 | Alice | Widget A | 180 | 2026-02-01 |
| 7 | Bob | Widget B | 220 | 2026-02-05 |
| 8 | Claire | Widget A | 310 | 2026-02-10 |
SELECT client, SUM(montant) AS total
FROM commandes
GROUP BY client;
<div style="overflow-x:auto;margin:20px 0;"><table>
<thead style="background-color:#0F172A;color:white;">
<tr><th style="padding:12px 16px;text-align:left">client</th><th style="padding:12px 16px;text-align:left">total</th></tr>
</thead><tbody>
<tr style="background:#f8fafc"><td style="padding:12px 16px">Alice</td><td style="padding:12px 16px">530</td></tr>
<tr><td style="padding:12px 16px">Bob</td><td style="padding:12px 16px">770</td></tr>
<tr style="background:#f8fafc"><td style="padding:12px 16px">Claire</td><td style="padding:12px 16px">485</td></tr>
</tbody></table></div>
Les 8 lignes sont réduites à 3 groupes (un par client). Pour chaque groupe, `SUM(montant)` calcule le total.
### L'ordre d'exécution SQL
Pour comprendre GROUP BY et HAVING, il faut connaître l'ordre d'exécution d'une requête SQL. Ce n'est **pas** l'ordre dans lequel vous écrivez les clauses :
1. **FROM** — sélection des tables et JOINs
2. **WHERE** — filtre les lignes individuelles
3. **GROUP BY** — regroupe les lignes
4. **HAVING** — filtre les groupes
5. **SELECT** — calcul des expressions et alias
6. **ORDER BY** — tri du résultat
7. **LIMIT** — restriction du nombre de lignes
<div class="callout callout-tip">
<p class="callout-title">💡 Bon à savoir</p>
<p>C'est pourquoi vous ne pouvez pas utiliser un alias défini dans SELECT à l'intérieur de WHERE ou HAVING (sauf dans certains SGBD comme MySQL qui le tolèrent).</p>
</div>
### GROUP BY sur plusieurs colonnes
Vous pouvez grouper sur plusieurs colonnes pour obtenir des sous-groupes plus fins :
```sql
SELECT client, produit, SUM(montant) AS total
FROM commandes
GROUP BY client, produit;
<div style="overflow-x:auto;margin:20px 0;"><table>
<thead style="background-color:#0F172A;color:white;">
<tr><th style="padding:12px 16px;text-align:left">client</th><th style="padding:12px 16px;text-align:left">produit</th><th style="padding:12px 16px;text-align:left">total</th></tr>
</thead><tbody>
<tr style="background:#f8fafc"><td style="padding:12px 16px">Alice</td><td style="padding:12px 16px">Widget A</td><td style="padding:12px 16px">330</td></tr>
<tr><td style="padding:12px 16px">Alice</td><td style="padding:12px 16px">Widget B</td><td style="padding:12px 16px">200</td></tr>
<tr style="background:#f8fafc"><td style="padding:12px 16px">Bob</td><td style="padding:12px 16px">Widget A</td><td style="padding:12px 16px">550</td></tr>
<tr><td style="padding:12px 16px">Bob</td><td style="padding:12px 16px">Widget B</td><td style="padding:12px 16px">220</td></tr>
<tr style="background:#f8fafc"><td style="padding:12px 16px">Claire</td><td style="padding:12px 16px">Widget A</td><td style="padding:12px 16px">310</td></tr>
<tr><td style="padding:12px 16px">Claire</td><td style="padding:12px 16px">Widget B</td><td style="padding:12px 16px">175</td></tr>
</tbody></table></div>
Chaque combinaison unique (client, produit) forme un groupe.
## Les fonctions d'agrégation
### Les 5 fonctions essentielles
<div style="overflow-x:auto;margin:20px 0;"><table>
<thead style="background-color:#0F172A;color:white;">
<tr><th style="padding:12px 16px;text-align:left">Fonction</th><th style="padding:12px 16px;text-align:left">Rôle</th><th style="padding:12px 16px;text-align:left">Traitement des NULL</th></tr>
</thead><tbody>
<tr style="background:#f8fafc"><td style="padding:12px 16px">COUNT(*)</td><td style="padding:12px 16px">Nombre de lignes</td><td style="padding:12px 16px">Compte toutes les lignes</td></tr>
<tr><td style="padding:12px 16px">COUNT(col)</td><td style="padding:12px 16px">Nombre de valeurs non-NULL</td><td style="padding:12px 16px">Ignore les NULL</td></tr>
<tr style="background:#f8fafc"><td style="padding:12px 16px">SUM(col)</td><td style="padding:12px 16px">Somme</td><td style="padding:12px 16px">Ignore les NULL</td></tr>
<tr><td style="padding:12px 16px">AVG(col)</td><td style="padding:12px 16px">Moyenne</td><td style="padding:12px 16px">Ignore les NULL</td></tr>
<tr style="background:#f8fafc"><td style="padding:12px 16px">MIN(col)</td><td style="padding:12px 16px">Valeur minimale</td><td style="padding:12px 16px">Ignore les NULL</td></tr>
<tr><td style="padding:12px 16px">MAX(col)</td><td style="padding:12px 16px">Valeur maximale</td><td style="padding:12px 16px">Ignore les NULL</td></tr>
</tbody></table></div>
### COUNT(*) vs COUNT(col) vs COUNT(DISTINCT col)
C'est une question classique d'entretien. Voici la différence :
```sql
SELECT
COUNT(*) AS nb_lignes, -- toutes les lignes
COUNT(produit) AS nb_produits, -- lignes où produit n'est pas NULL
COUNT(DISTINCT produit) AS nb_produits_uniques -- valeurs uniques de produit
FROM commandes;
| nb_lignes | nb_produits | nb_produits_uniques |
|---|---|---|
| 8 | 8 | 2 |
Les fonctions conditionnelles
Combiner une agrégation avec CASE WHEN est un pattern très puissant :
SELECT
client,
COUNT(*) AS nb_commandes,
SUM(CASE WHEN produit = 'Widget A' THEN montant ELSE 0 END) AS total_widget_a,
SUM(CASE WHEN produit = 'Widget B' THEN montant ELSE 0 END) AS total_widget_b
FROM commandes
GROUP BY client;
| client | nb_commandes | total_widget_a | total_widget_b |
|---|---|---|---|
| Alice | 3 | 330 | 200 |
| Bob | 3 | 550 | 220 |
| Claire | 2 | 310 | 175 |
Ce pattern est souvent appelé « pivot » et revient fréquemment en entretien.
HAVING : filtrer les groupes
Différence entre WHERE et HAVING
C'est LA question qui revient dans tous les entretiens SQL :
- WHERE filtre les lignes individuelles avant le regroupement
- HAVING filtre les groupes après le regroupement
-- WHERE : filtre les commandes de janvier avant le GROUP BY
SELECT client, SUM(montant) AS total
FROM commandes
WHERE date_commande >= '2026-01-01' AND date_commande < '2026-02-01'
GROUP BY client;
| client | total |
|---|---|
| Alice | 350 |
| Bob | 550 |
| Claire | 175 |
-- HAVING : filtre les groupes dont le total dépasse 400
SELECT client, SUM(montant) AS total
FROM commandes
GROUP BY client
HAVING SUM(montant) > 400;
| client | total |
|---|---|
| Alice | 530 |
| Bob | 770 |
Combiner WHERE et HAVING
On peut combiner les deux : WHERE filtre d'abord les lignes, GROUP BY regroupe, puis HAVING filtre les groupes :
SELECT client, SUM(montant) AS total
FROM commandes
WHERE produit = 'Widget A'
GROUP BY client
HAVING SUM(montant) > 200;
Étape par étape :
- WHERE ne garde que les lignes avec produit = 'Widget A' (5 lignes)
- GROUP BY regroupe par client
- HAVING ne garde que les groupes dont le total > 200
| client | total |
|---|---|
| Alice | 330 |
| Bob | 550 |
| Claire | 310 |
HAVING avec COUNT
Pattern très courant — trouver les clients ayant passé plus de 2 commandes :
SELECT client, COUNT(*) AS nb_commandes
FROM commandes
GROUP BY client
HAVING COUNT(*) > 2;
| client | nb_commandes |
|---|---|
| Alice | 3 |
| Bob | 3 |
HAVING sans GROUP BY
Oui, HAVING peut fonctionner sans GROUP BY explicite. Dans ce cas, toute la table est considérée comme un seul groupe :
-- Vérifier si la table contient plus de 5 lignes
SELECT COUNT(*) AS nb_total
FROM commandes
HAVING COUNT(*) > 5;
GROUP BY : cas avancés
GROUP BY avec des expressions
Vous pouvez grouper sur une expression calculée :
-- Ventes par mois
SELECT
DATE_TRUNC('month', date_commande) AS mois,
SUM(montant) AS total
FROM commandes
GROUP BY DATE_TRUNC('month', date_commande)
ORDER BY mois;
Ou en MySQL :
SELECT
DATE_FORMAT(date_commande, '%Y-%m') AS mois,
SUM(montant) AS total
FROM commandes
GROUP BY DATE_FORMAT(date_commande, '%Y-%m')
ORDER BY mois;
GROUP BY avec ROLLUP
ROLLUP ajoute des lignes de sous-totaux et un grand total :
SELECT
client,
produit,
SUM(montant) AS total
FROM commandes
GROUP BY ROLLUP(client, produit);
| client | produit | total |
|---|---|---|
| Alice | Widget A | 330 |
| Alice | Widget B | 200 |
| Alice | NULL | 530 |
| Bob | Widget A | 550 |
| Bob | Widget B | 220 |
| Bob | NULL | 770 |
| Claire | Widget A | 310 |
| Claire | Widget B | 175 |
| Claire | NULL | 485 |
| NULL | NULL | 1785 |
Les lignes avec NULL dans produit sont les sous-totaux par client. La dernière ligne est le grand total.
GROUP BY avec CUBE
CUBE génère toutes les combinaisons possibles de sous-totaux :
SELECT
client,
produit,
SUM(montant) AS total
FROM commandes
GROUP BY CUBE(client, produit);
Cela ajoute aussi les sous-totaux par produit (tous clients confondus), en plus de ce que ROLLUP fournit.
GROUP BY avec GROUPING SETS
GROUPING SETS permet de spécifier exactement quels niveaux d'agrégation vous voulez :
SELECT
client,
produit,
SUM(montant) AS total
FROM commandes
GROUP BY GROUPING SETS (
(client, produit), -- détail
(client), -- total par client
() -- grand total
);
Les erreurs courantes avec GROUP BY
Erreur 1 : colonne non agrégée dans SELECT
C'est l'erreur la plus fréquente :
-- ERREUR : date_commande n'est ni dans GROUP BY ni agrégée
SELECT client, date_commande, SUM(montant)
FROM commandes
GROUP BY client;
⚠️ Attention
Chaque colonne dans SELECT doit être soit dans GROUP BY, soit dans une fonction d'agrégation. PostgreSQL, SQL Server et SQLite rejettent cette requête. MySQL en mode non-strict la tolère mais renvoie une valeur arbitraire pour date_commande.
Erreur 2 : HAVING au lieu de WHERE
-- Mauvaise pratique : utiliser HAVING pour filtrer des lignes
SELECT client, SUM(montant) AS total
FROM commandes
GROUP BY client
HAVING client = 'Alice';
-- Bonne pratique : utiliser WHERE
SELECT client, SUM(montant) AS total
FROM commandes
WHERE client = 'Alice'
GROUP BY client;
HAVING fonctionne dans les deux cas, mais le filtre dans WHERE est appliqué avant le regroupement, ce qui est plus performant.
Erreur 3 : Confondre COUNT(*) et COUNT(col) avec les JOINs
-- Avec un LEFT JOIN, COUNT(*) compte aussi les lignes NULL
SELECT
c.nom,
COUNT(*) AS nb_commandes -- INCORRECT : vaut 1 même sans commande
FROM clients c
LEFT JOIN commandes co ON c.id = co.client_id
GROUP BY c.nom;
-- Correct : compter sur une colonne de la table de droite
SELECT
c.nom,
COUNT(co.id) AS nb_commandes -- 0 si pas de commande
FROM clients c
LEFT JOIN commandes co ON c.id = co.client_id
GROUP BY c.nom;
Erreur 4 : Oublier DISTINCT dans COUNT
-- Nombre de clients distincts qui ont commandé chaque produit
SELECT
produit,
COUNT(client) AS nb_clients, -- compte les doublons
COUNT(DISTINCT client) AS nb_clients_uniques -- correct
FROM commandes
GROUP BY produit;
Erreur 5 : Mauvaise gestion des NULL en agrégation
-- Piège classique avec AVG
SELECT
client,
AVG(remise) AS remise_moyenne -- NULL si tous sont NULL
FROM commandes
GROUP BY client;
-- Plus robuste avec COALESCE
SELECT
client,
AVG(COALESCE(remise, 0)) AS remise_moyenne
FROM commandes
GROUP BY client;
Erreur 6 : GROUP BY avec des colonnes calculées
-- ERREUR : l'expression doit être répétée
SELECT
YEAR(date_commande) * 100 + MONTH(date_commande) AS periode,
SUM(montant) AS total
FROM commandes
GROUP BY periode; -- certains SGBD l'acceptent, d'autres non
-- Solution portable
SELECT
YEAR(date_commande) * 100 + MONTH(date_commande) AS periode,
SUM(montant) AS total
FROM commandes
GROUP BY YEAR(date_commande) * 100 + MONTH(date_commande);
Erreur 7 : Mélanger agrégation et non-agrégation dans HAVING
-- ERREUR courante dans certains SGBD
SELECT client, SUM(montant) AS total
FROM commandes
GROUP BY client
HAVING montant > 200; -- montant n'est pas agrégé !
-- Correct : utiliser WHERE pour filtrer les lignes
SELECT client, SUM(montant) AS total
FROM commandes
WHERE montant > 200
GROUP BY client;
-- Ou utiliser HAVING avec agrégation
SELECT client, SUM(montant) AS total
FROM commandes
GROUP BY client
HAVING MAX(montant) > 200;
Les bonnes pratiques et patterns avancés
Gestion des valeurs NULL dans les fonctions d'agrégation
Les fonctions d'agrégation ignorent les valeurs NULL, ce qui peut parfois créer des surprises :
-- Si certains montants sont NULL
SELECT
client,
COUNT(*) AS nb_lignes, -- compte toutes les lignes
COUNT(montant) AS nb_montants, -- ignore les NULL
SUM(montant) AS total, -- ignore les NULL
AVG(montant) AS moyenne -- moyenner sur les non-NULL uniquement
FROM commandes
GROUP BY client;
💡 Bon à savoir
Pour traiter les NULL comme des zéros, utilisez COALESCE : SUM(COALESCE(montant, 0)). Pour considérer les NULL dans le calcul de moyenne : AVG(COALESCE(montant, 0)).
Pattern de classification par tranche
Un pattern très courant en analyse de données : classer les clients par tranche de dépenses.
SELECT
CASE
WHEN SUM(montant) < 200 THEN 'Petit client'
WHEN SUM(montant) < 500 THEN 'Client moyen'
ELSE 'Gros client'
END AS categorie,
COUNT(*) AS nb_clients
FROM commandes
GROUP BY
CASE
WHEN SUM(montant) < 200 THEN 'Petit client'
WHEN SUM(montant) < 500 THEN 'Client moyen'
ELSE 'Gros client'
END;
Analyser les tendances avec LAG et GROUP BY
Combiner GROUP BY avec les fonctions fenêtre pour analyser l'évolution :
SELECT
mois,
total_mois,
LAG(total_mois) OVER (ORDER BY mois) AS total_mois_precedent,
total_mois - LAG(total_mois) OVER (ORDER BY mois) AS evolution
FROM (
SELECT
DATE_TRUNC('month', date_commande) AS mois,
SUM(montant) AS total_mois
FROM commandes
GROUP BY DATE_TRUNC('month', date_commande)
) monthly_sales
ORDER BY mois;
Identifier les clients à risque
Pattern métier : clients qui n'ont pas commandé depuis longtemps.
SELECT
client,
MAX(date_commande) AS derniere_commande,
CURRENT_DATE - MAX(date_commande) AS jours_sans_commande
FROM commandes
GROUP BY client
HAVING CURRENT_DATE - MAX(date_commande) > 30
ORDER BY jours_sans_commande DESC;
Calculs de percentiles avec GROUP BY
Pattern avancé pour calculer des percentiles par groupe :
SELECT
produit,
COUNT(*) AS nb_commandes,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY montant) AS mediane,
PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY montant) AS p90
FROM commandes
GROUP BY produit;
GROUP BY selon le type de profil
Pour les débutants en SQL
Si vous débutez en SQL, concentrez-vous sur ces patterns de base :
| Pattern | Cas d'usage | Exemple typique |
|---|---|---|
| GROUP BY + COUNT(*) | Compter par catégorie | Nombre de commandes par client |
| GROUP BY + SUM() | Totaliser par groupe | Chiffre d'affaires par produit |
| GROUP BY + HAVING | Filtrer après agrégation | Clients ayant commandé > 3 fois |
| GROUP BY multiple | Sous-groupes détaillés | Ventes par mois et par région |
Pour les data analysts
En tant que data analyst, vous devez maîtriser ces techniques avancées fréquemment utilisées :
-- Analyse de cohorte simplifiée
SELECT
DATE_TRUNC('month', premiere_commande) AS cohorte,
COUNT(*) AS nb_clients_acquis,
AVG(total_depense) AS panier_moyen_cohorte
FROM (
SELECT
client,
MIN(date_commande) AS premiere_commande,
SUM(montant) AS total_depense
FROM commandes
GROUP BY client
) client_stats
GROUP BY DATE_TRUNC('month', premiere_commande)
ORDER BY cohorte;
Pour les développeurs
Les développeurs doivent optimiser les performances et gérer les cas edge :
-- Utilisation d'index sur les colonnes GROUP BY
CREATE INDEX idx_commandes_client_date ON commandes (client, date_commande);
-- GROUP BY avec gestion des cas NULL
SELECT
COALESCE(region, 'Non spécifié') AS region,
COUNT(*) AS nb_commandes,
SUM(COALESCE(montant, 0)) AS total
FROM commandes
GROUP BY COALESCE(region, 'Non spécifié')
HAVING COUNT(*) >= 10; -- Éviter les groupes avec peu de données
Optimisation et performance
Indexation pour GROUP BY
Pour optimiser vos requêtes GROUP BY, créez des index composites :
-- Si vous groupez souvent par (client, produit)
CREATE INDEX idx_commandes_group BY ON commandes (client, produit);
-- Include des colonnes souvent agrégées
CREATE INDEX idx_commandes_optimized ON commandes (client, produit) INCLUDE (montant, date_commande);
Éviter les GROUP BY coûteux
⚠️ Attention
Grouper sur des expressions complexes ou des fonctions est coûteux. Privilégiez le précalcul ou l'utilisation de colonnes indexées quand possible.
-- Coûteux : calcul à chaque ligne
SELECT
CONCAT(UPPER(LEFT(nom, 1)), LOWER(SUBSTRING(nom, 2))) AS nom_formate,
COUNT(*)
FROM clients
GROUP BY CONCAT(UPPER(LEFT(nom, 1)), LOWER(SUBSTRING(nom, 2)));
-- Mieux : précalculer dans une colonne
ALTER TABLE clients ADD COLUMN nom_formate VARCHAR(100);
UPDATE clients SET nom_formate = CONCAT(UPPER(LEFT(nom, 1)), LOWER(SUBSTRING(nom, 2)));
CREATE INDEX ON clients (nom_formate);
SELECT nom_formate, COUNT(*)
FROM clients
GROUP BY nom_formate;
10 exercices corrigés GROUP BY et HAVING
Exercice 1 : Ventes par trimestre
Énoncé : Calculez le chiffre d'affaires par trimestre pour l'année 2026.
Données :
CREATE TABLE ventes (
id INT,
date_vente DATE,
montant DECIMAL(10,2),
vendeur VARCHAR(50)
);
Solution
SELECT
CONCAT('Q', QUARTER(date_vente), ' ', YEAR(date_vente)) AS trimestre,
SUM(montant) AS ca_trimestre,
COUNT(*) AS nb_ventes
FROM ventes
WHERE YEAR(date_vente) = 2026
GROUP BY QUARTER(date_vente), YEAR(date_vente)
ORDER BY YEAR(date_vente), QUARTER(date_vente);
Points clés :
- GROUP BY sur une fonction (QUARTER)
- WHERE pour filtrer l'année avant regroupement
- ORDER BY pour tri chronologique
Exercice 2 : TOP 5 des clients
Énoncé : Trouvez les 5 clients ayant généré le plus de chiffre d'affaires, avec au moins 3 commandes.
Solution
SELECT
client,
COUNT(*) AS nb_commandes,
SUM(montant) AS ca_total,
AVG(montant) AS panier_moyen
FROM commandes
GROUP BY client
HAVING COUNT(*) >= 3
ORDER BY SUM(montant) DESC
LIMIT 5;
Points clés :
- HAVING avec COUNT pour filtrer les groupes
- ORDER BY sur une agrégation
- LIMIT pour le TOP 5
Exercice 3 : Analyse des doublons
Énoncé : Identifiez les combinaisons (client, produit, date) qui apparaissent plusieurs fois.
Solution
SELECT
client,
produit,
date_commande,
COUNT(*) AS nb_occurrences
FROM commandes
GROUP BY client, produit, date_commande
HAVING COUNT(*) > 1
ORDER BY COUNT(*) DESC, client;
Points clés :
- GROUP BY sur plusieurs colonnes
- HAVING > 1 pour trouver les doublons
- Tri par nombre d'occurrences décroissant
Exercice 4 : Produits jamais vendus
Énoncé : À partir des tables produits et commandes, trouvez les produits qui n'ont jamais été commandés.
Solution
SELECT
p.nom_produit,
p.categorie,
COUNT(c.id) AS nb_commandes
FROM produits p
LEFT JOIN commandes c ON p.id = c.produit_id
GROUP BY p.id, p.nom_produit, p.categorie
HAVING COUNT(c.id) = 0
ORDER BY p.categorie, p.nom_produit;
Alternative avec NOT EXISTS :
SELECT nom_produit, categorie
FROM produits p
WHERE NOT EXISTS (
SELECT 1 FROM commandes c WHERE c.produit_id = p.id
);
Points clés :
- LEFT JOIN pour garder tous les produits
- COUNT sur colonne de la table de droite (attention à COUNT(*) vs COUNT(col))
- HAVING = 0 pour les produits non vendus
Exercice 5 : Croissance mensuelle
Énoncé : Calculez la croissance du CA mensuel par rapport au mois précédent.
Solution
WITH ca_mensuel AS (
SELECT
DATE_TRUNC('month', date_commande) AS mois,
SUM(montant) AS ca
FROM commandes
GROUP BY DATE_TRUNC('month', date_commande)
)
SELECT
mois,
ca,
LAG(ca) OVER (ORDER BY mois) AS ca_precedent,
ca - LAG(ca) OVER (ORDER BY mois) AS croissance_absolue,
ROUND(
(ca - LAG(ca) OVER (ORDER BY mois)) * 100.0 / LAG(ca) OVER (ORDER BY mois),
2
) AS croissance_pct
FROM ca_mensuel
ORDER BY mois;
Points clés :
- CTE pour clarifier le code
- GROUP BY avec expression de date
- Fonction fenêtre LAG pour comparer avec la période précédente
Exercice 6 : Analyse ABC des produits
Énoncé : Classez les produits en catégories A (80% du CA), B (15%) et C (5%).
Solution
WITH ca_produits AS (
SELECT
produit,
SUM(montant) AS ca_produit
FROM commandes
GROUP BY produit
),
ca_cumule AS (
SELECT
produit,
ca_produit,
SUM(ca_produit) OVER (ORDER BY ca_produit DESC) AS ca_cumule,
SUM(ca_produit) OVER () AS ca_total
FROM ca_produits
)
SELECT
produit,
ca_produit,
ROUND(ca_produit * 100.0 / ca_total, 2) AS pct_ca,
ROUND(ca_cumule * 100.0 / ca_total, 2) AS pct_cumule,
CASE
WHEN ca_cumule * 100.0 / ca_total <= 80 THEN 'A'
WHEN ca_cumule * 100.0 / ca_total <= 95 THEN 'B'
ELSE 'C'
END AS categorie_abc
FROM ca_cumule
ORDER BY ca_produit DESC;
Points clés :
- Plusieurs CTEs pour structurer la logique
- Fonction fenêtre SUM() OVER pour le cumul
- Classification avec CASE WHEN
Exercice 7 : Clients fidèles vs nouveaux
Énoncé : Pour chaque mois, comptez les clients nouveaux vs récurrents.
Solution
WITH premieres_commandes AS (
SELECT
client,
MIN(date_commande) AS premiere_commande
FROM commandes
GROUP BY client
),
commandes_enrichies AS (
SELECT
c.*,
DATE_TRUNC('month', c.date_commande) AS mois,
DATE_TRUNC('month', pc.premiere_commande) AS mois_acquisition
FROM commandes c
JOIN premieres_commandes pc ON c.client = pc.client
)
SELECT
mois,
COUNT(DISTINCT CASE WHEN mois = mois_acquisition THEN client END) AS nouveaux_clients,
COUNT(DISTINCT CASE WHEN mois != mois_acquisition THEN client END) AS clients_recurrents,
COUNT(DISTINCT client) AS total_clients_actifs
FROM commandes_enrichies
GROUP BY mois
ORDER BY mois;
Points clés :
- Identification de la première commande par client
- Jointure pour enrichir les données
- COUNT DISTINCT avec CASE WHEN pour compter conditionnellement
Exercice 8 : Détection d'anomalies
Énoncé : Trouvez les jours où le CA a été exceptionnellement élevé (> 2x la moyenne).
Solution
WITH ca_quotidien AS (
SELECT
DATE(date_commande) AS jour,
SUM(montant) AS ca_jour,
COUNT(*) AS nb_commandes
FROM commandes
GROUP BY DATE(date_commande)
),
stats_globales AS (
SELECT
AVG(ca_jour) AS ca_moyen,
STDDEV(ca_jour) AS ca_stddev
FROM ca_quotidien
)
SELECT
cq.jour,
cq.ca_jour,
cq.nb_commandes,
ROUND(cq.ca_jour / sg.ca_moyen, 2) AS ratio_vs_moyenne,
ROUND((cq.ca_jour - sg.ca_moyen) / sg.ca_stddev, 2) AS z_score
FROM ca_quotidien cq
CROSS JOIN stats_globales sg
WHERE cq.ca_jour > sg.ca_moyen * 2
ORDER BY cq.ca_jour DESC;
Points clés :
- GROUP BY par jour
- Calcul de statistiques globales dans une CTE
- CROSS JOIN pour comparer chaque jour aux stats globales
- Z-score pour mesurer l'écart à la normale
Exercice 9 : Retention mensuelle
Énoncé : Calculez le taux de retention mensuel (clients actifs le mois M qui sont encore actifs le mois M+1).
Solution
WITH clients_mensuels AS (
SELECT DISTINCT
DATE_TRUNC('month', date_commande) AS mois,
client
FROM commandes
),
retention AS (
SELECT
cm1.mois,
COUNT(cm1.client) AS clients_actifs,
COUNT(cm2.client) AS clients_revenus,
ROUND(COUNT(cm2.client) * 100.0 / COUNT(cm1.client), 2) AS taux_retention
FROM clients_mensuels cm1
LEFT JOIN clients_mensuels cm2 ON cm1.client = cm2.client
AND cm2.mois = cm1.mois + INTERVAL '1 month'
GROUP BY cm1.mois
)
SELECT *
FROM retention
ORDER BY mois;
Points clés :
- DISTINCT pour éviter les doublons par client/mois
- Self-join avec décalage temporel
- Calcul du ratio de rétention
Exercice 10 : Analyse de panier complexe
Énoncé : Créez un rapport de segmentation client basé sur RFM (Récence, Fréquence, Montant).
Solution
WITH rfm_base AS (
SELECT
client,
MAX(date_commande) AS derniere_commande,
COUNT(*) AS frequence,
SUM(montant) AS montant_total,
AVG(montant) AS panier_moyen
FROM commandes
WHERE date_commande >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY client
),
rfm_scores AS (
SELECT
client,
derniere_commande,
frequence,
montant_total,
panier_moyen,
CURRENT_DATE - derniere_commande AS jours_depuis,
NTILE(5) OVER (ORDER BY MAX(date_commande) DESC) AS score_r,
NTILE(5) OVER (ORDER BY COUNT(*)) AS score_f,
NTILE(5) OVER (ORDER BY SUM(montant)) AS score_m
FROM commandes
WHERE date_commande >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY client
),
segments AS (
SELECT
*,
CASE
WHEN score_r >= 4 AND score_f >= 4 AND score_m >= 4 THEN 'Champions'
WHEN score_r >= 3 AND score_f >= 3 AND score_m >= 3 THEN 'Loyal Customers'
WHEN score_r >= 4 AND score_f <= 2 THEN 'New Customers'
WHEN score_r <= 2 AND score_f >= 3 THEN 'At Risk'
WHEN score_r <= 2 AND score_f <= 2 THEN 'Lost'
ELSE 'Regular Customers'
END AS segment
FROM rfm_scores
)
SELECT
segment,
COUNT(*) AS nb_clients,
AVG(frequence) AS freq_moyenne,
AVG(montant_total) AS ca_moyen,
AVG(jours_depuis) AS recence_moyenne
FROM segments
GROUP BY segment
ORDER BY nb_clients DESC;
Points clés :
- Segmentation RFM avec NTILE pour créer des quintiles
- Classification business avec CASE WHEN
- Analyse finale par segment
Récapitulatif des différences SGBD
Les implémentations de GROUP BY varient selon les systèmes de gestion de base de données :
| SGBD | Particularités GROUP BY | Extensions spéciales |
|---|---|---|
| PostgreSQL | Strict sur les colonnes SELECT | GROUPING SETS, ROLLUP, CUBE |
| MySQL | Mode strict configurable | WITH ROLLUP |
| SQL Server | Strict, supporte ROLLUP | GROUPING(), ROLLUP, CUBE |
| Oracle | Fonctionnalités avancées | GROUPING SETS, CONNECT BY |
| SQLite | Standard SQL basique | Fonctionnalités limitées |
Exemples concrets par SGBD
MySQL avec WITH ROLLUP :
SELECT region, produit, SUM(montant)
FROM ventes
GROUP BY region, produit WITH ROLLUP;
PostgreSQL avec GROUPING SETS :
SELECT region, produit, SUM(montant)
FROM ventes
GROUP BY GROUPING SETS ((region, produit), (region), ());
SQL Server avec OVER :
SELECT client,
SUM(montant) AS total,
SUM(SUM(montant)) OVER () AS grand_total
FROM commandes
GROUP BY client;
Métriques de performance à connaître
Selon une étude Stack Overflow 2023, 67% des développeurs utilisent GROUP BY quotidiennement. Voici les métriques importantes :
- Temps d'exécution : les GROUP BY représentent 23% du temps CPU des requêtes analytiques moyennes
- Utilisation mémoire : GROUP BY consomme environ 2-3x plus de RAM qu'une simple sélection
- Impact des index : un index composite peut améliorer les performances de GROUP BY de 5 à 50x selon la cardinalité
Benchmark des patterns courants
| Pattern | Performance sur 1M lignes | Recommandation |
|---|---|---|
| GROUP BY colonne indexée | ~200ms | Optimal |
| GROUP BY expression | ~800ms | Précalculer si possible |
| GROUP BY + HAVING | ~250ms | Bien optimisé |
| ROLLUP sur 3 colonnes | ~1,2s | Limiter le nombre de niveaux |
Vous souhaitez approfondir vos compétences SQL pour vos entretiens techniques ? Consultez notre guide sur les 20 questions SQL les plus posées en entretien ou apprenez à maîtriser les JOINs SQL avec des exercices pratiques.
Questions fréquentes
Quelle est la différence entre WHERE et HAVING ?
WHERE filtre les lignes individuelles avant le regroupement, tandis que HAVING filtre les groupes après l'agrégation. WHERE s'exécute en premier et est plus performant pour les filtres simples. HAVING ne peut filtrer que sur des colonnes présentes dans GROUP BY ou des fonctions d'agrégation.
Pourquoi COUNT(*) et COUNT(colonne) donnent-ils des résultats différents ?
COUNT(*) compte toutes les lignes du groupe, y compris celles avec des valeurs NULL. COUNT(colonne) ne compte que les lignes où la colonne spécifiée n'est pas NULL. COUNT(DISTINCT colonne) compte uniquement les valeurs distinctes non-NULL.
Peut-on utiliser GROUP BY sans fonction d'agrégation ?
Techniquement oui, mais c'est généralement inutile. GROUP BY sans agrégation équivaut à un DISTINCT sur les colonnes spécifiées. Il est plus lisible et performant d'utiliser SELECT DISTINCT dans ce cas.
Comment gérer les colonnes calculées dans GROUP BY ?
Vous devez répéter l'expression complète dans GROUP BY, car l'alias défini dans SELECT n'est pas encore disponible. Certains SGBD comme MySQL tolèrent l'utilisation de l'alias, mais ce n'est pas portable. Alternativement, utilisez une CTE pour clarifier le code.
ROLLUP, CUBE et GROUPING SETS : quelles différences ?
ROLLUP génère des sous-totaux hiérarchiques (A+B, A, total). CUBE génère toutes les combinaisons possibles de sous-totaux (A+B, A, B, total). GROUPING SETS permet de spécifier exactement quels niveaux d'agrégation vous voulez, offrant plus de contrôle.
Pourquoi ma requête GROUP BY est-elle lente ?
Les causes principales sont : absence d'index sur les colonnes GROUP BY, regroupement sur des expressions complexes, trop de groupes distincts, ou utilisation de ROLLUP/CUBE sur de gros volumes. Créez des index composites et évitez les fonctions coûteuses dans GROUP BY.
Comment optimiser GROUP BY avec JOIN ?
Placez les conditions de filtre dans WHERE plutôt que HAVING quand possible. Créez des index sur les colonnes de jointure et de regroupement. Considérez filtrer avant le JOIN avec des sous-requêtes. Pour les LEFT JOIN, attention à COUNT(*) vs COUNT(colonne_droite).
