SQL Pratique
GROUP BY et HAVING en SQL : guide complet avec exercices
24 min de lecture

GROUP BY et HAVING en SQL : guide complet avec exercices

Maîtrisez GROUP BY et HAVING en SQL : syntaxe, fonctions d'agrégation, erreurs courantes et 10 exercices corrigés pour l'entretien technique.

Avatar de Thomas LeroyThomas Leroy

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 :

idclientproduitmontantdate_commande
1AliceWidget A1502026-01-05
2AliceWidget B2002026-01-12
3BobWidget A3002026-01-08
4BobWidget A2502026-01-20
5ClaireWidget B1752026-01-15
6AliceWidget A1802026-02-01
7BobWidget B2202026-02-05
8ClaireWidget A3102026-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_lignesnb_produitsnb_produits_uniques
882

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;
clientnb_commandestotal_widget_atotal_widget_b
Alice3330200
Bob3550220
Claire2310175

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;
clienttotal
Alice350
Bob550
Claire175
-- 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;
clienttotal
Alice530
Bob770

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 :

  1. WHERE ne garde que les lignes avec produit = 'Widget A' (5 lignes)
  2. GROUP BY regroupe par client
  3. HAVING ne garde que les groupes dont le total > 200
clienttotal
Alice330
Bob550
Claire310

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;
clientnb_commandes
Alice3
Bob3

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);
clientproduittotal
AliceWidget A330
AliceWidget B200
AliceNULL530
BobWidget A550
BobWidget B220
BobNULL770
ClaireWidget A310
ClaireWidget B175
ClaireNULL485
NULLNULL1785

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 :

PatternCas d'usageExemple typique
GROUP BY + COUNT(*)Compter par catégorieNombre de commandes par client
GROUP BY + SUM()Totaliser par groupeChiffre d'affaires par produit
GROUP BY + HAVINGFiltrer après agrégationClients ayant commandé > 3 fois
GROUP BY multipleSous-groupes détaillésVentes 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 :

SGBDParticularités GROUP BYExtensions spéciales
PostgreSQLStrict sur les colonnes SELECTGROUPING SETS, ROLLUP, CUBE
MySQLMode strict configurableWITH ROLLUP
SQL ServerStrict, supporte ROLLUPGROUPING(), ROLLUP, CUBE
OracleFonctionnalités avancéesGROUPING SETS, CONNECT BY
SQLiteStandard SQL basiqueFonctionnalité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

PatternPerformance sur 1M lignesRecommandation
GROUP BY colonne indexée~200msOptimal
GROUP BY expression~800msPrécalculer si possible
GROUP BY + HAVING~250msBien optimisé
ROLLUP sur 3 colonnes~1,2sLimiter 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).

Prêt à vous entraîner ?

50 exercices SQL interactifs avec éditeur en ligne, chronomètre et feedback IA.

Voir les exercices