1. Introduction
Dans le précédent cas pratique, nous avons nettoyé un dataset de 63 000 ventes. Nous disposons désormais d’une table clean_sales de qualité, prête pour l’analyse.
Ce second cas pratique explore les fonctions de fenêtrage (window functions) de SQL : SUM() OVER, LAG(), RANK(), AVG() OVER avec fenêtre glissante. Ces fonctions permettent de réaliser des analyses avancées sans sous-requêtes complexes.
Objectifs pédagogiques
À l’issue de ce cas pratique, vous saurez :
- Calculer des cumuls et parts de marché avec SUM() OVER
- Comparer des valeurs entre lignes avec LAG() et LEAD()
- Classer des données avec RANK() et DENSE_RANK()
- Lisser des séries temporelles avec des moyennes mobiles
- Analyser des évolutions annuelles avec LAG(..., 12)

Figure 1 : Aperçu de la table clean_sales après nettoyage.
2. Prérequis
Pour suivre ce cas pratique, vous avez besoin de :
- SQLite et DB Browser for SQLite (gratuits)
- Le dataset nettoyé
clean_sales_data.csvou la basesales_cleaning.db
Télécharger le dataset nettoyé clean_sales_data.csv (5.8 Mo)
Télécharger la base SQLite sales_cleaning.db (4.2 Mo)
3. Correction préalable : les dates au format ISO
Problème identifié
SQLite ne possède pas de type DATE natif. Les dates sont stockées au format MM-DD-YYYY et triées alphabétiquement, pas chronologiquement. Cela fausse toutes les analyses temporelles.

Figure 2 : Avant correction – les dates ne sont pas triées chronologiquement.
Solution : Ajouter une colonne Sale_Date_ISO au format YYYY-MM-DD.
Ajout de la colonne date ISO
ALTER TABLE clean_sales ADD COLUMN Sale_Date_ISO TEXT;
UPDATE clean_sales
SET Sale_Date_ISO = SUBSTR(Sale_Date_Clean, 7, 4) || '-' ||
SUBSTR(Sale_Date_Clean, 1, 2) || '-' ||
SUBSTR(Sale_Date_Clean, 4, 2)
WHERE Sale_Date_Clean LIKE '__-__-____';
Figure 3 : Vérification – les dates sont maintenant au format ISO.
4. Correction préalable : les catégories de produits NULL
Deuxième problème identifié
Certains produits avaient une catégorie NULL, ce qui perturbait les classements. La solution : imputer la catégorie la plus fréquente pour chaque produit.
Imputation des catégories manquantes
-- 1. Table de correspondance (produit → catégorie la plus fréquente)
CREATE TABLE product_category_map AS
SELECT
Product_Name,
Product_Category,
COUNT(*) AS freq,
ROW_NUMBER() OVER (PARTITION BY Product_Name ORDER BY COUNT(*) DESC) AS rn
FROM clean_sales
WHERE Product_Category IS NOT NULL
GROUP BY Product_Name, Product_Category;
-- 2. Garder seulement la catégorie la plus fréquente par produit
CREATE TABLE product_category_final AS
SELECT Product_Name, Product_Category
FROM product_category_map
WHERE rn = 1;
-- 3. Mise à jour des NULL
UPDATE clean_sales
SET Product_Category = (
SELECT Product_Category
FROM product_category_final
WHERE product_category_final.Product_Name = clean_sales.Product_Name
)
WHERE Product_Category IS NULL;
Figure 4 : Après correction – plus de NULL, classement propre par catégorie.
5. Cumul des ventes par région
Objectif : suivre l’évolution cumulée du chiffre d’affaires pour chaque région.
Cumul des ventes par région
SELECT
COALESCE(Store_Location, 'Inconnu') AS Store_Location,
Sale_Date_Clean,
Total_Amount,
SUM(Total_Amount) OVER (
PARTITION BY COALESCE(Store_Location, 'Inconnu')
ORDER BY Sale_Date_Clean
) AS cumul_ventes
FROM clean_sales
ORDER BY COALESCE(Store_Location, 'Inconnu'), Sale_Date_Clean
LIMIT 30;Explication :
PARTITION BY Store_Location redémarre le cumul à chaque région.
ORDER BY Sale_Date_Clean cumule dans l’ordre chronologique.
COALESCE(..., 'Inconnu') regroupe les 641 lignes sans localisation sous une étiquette explicite.

Figure 5 : Évolution cumulée des ventes – CHICAGO, IL en exemple.
6. Variation journalière (LAG)
Objectif : comparer le chiffre d’affaires d’un jour avec celui de la veille.
Variation par rapport à la veille
SELECT
Sale_Date_ISO,
ROUND(SUM(Total_Amount), 2) AS ventes_journalieres,
ROUND(LAG(SUM(Total_Amount), 1) OVER (ORDER BY Sale_Date_ISO), 2) AS ventes_veille,
ROUND(SUM(Total_Amount) - LAG(SUM(Total_Amount), 1) OVER (ORDER BY Sale_Date_ISO), 2) AS variation
FROM clean_sales
WHERE Sale_Date_ISO IS NOT NULL
GROUP BY Sale_Date_ISO
ORDER BY Sale_Date_ISO
LIMIT 30;Explication :
LAG(Total_Amount, 1) accède à la valeur de la ligne précédente (la veille).
La première ligne a ventes_veille = NULL (pas de jour précédent).
La colonne variation calcule la différence entre aujourd’hui et hier.

Figure 6 : Après correction des dates, la variation est maintenant chronologique.
7. Classement des produits par catégorie (RANK)
Objectif : identifier les produits les plus vendus dans chaque catégorie.
Classement des produits
SELECT
Product_Category,
Product_Name,
ROUND(SUM(Total_Amount), 2) AS total_ventes,
RANK() OVER (PARTITION BY Product_Category ORDER BY SUM(Total_Amount) DESC) AS rang
FROM clean_sales
GROUP BY Product_Category, Product_Name
ORDER BY Product_Category, rang
LIMIT 30;Explication :
RANK() OVER (PARTITION BY Product_Category ORDER BY SUM(Total_Amount) DESC)
- Classe les produits au sein de chaque catégorie (partie)
- Le meilleur produit a le rang 1
- En cas d’égalité, RANK saute des numéros (ex: 1,1,3)

Figure 7 : Top produits – dans chaque catégorie, les meilleures ventes sont identifiées.
8. Moyenne mobile 7 jours
Objectif : lisser les variations quotidiennes pour visualiser la tendance.
Moyenne mobile 7 jours
SELECT
Sale_Date_ISO,
ROUND(Total_Amount, 2) AS Total_Amount,
ROUND(AVG(Total_Amount) OVER (
ORDER BY Sale_Date_ISO
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 2) AS moyenne_mobile_7j
FROM clean_sales
WHERE Sale_Date_ISO IS NOT NULL
ORDER BY Sale_Date_ISO
LIMIT 30;Explication :
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW définit une fenêtre glissante de 7 lignes (les 6 précédentes + la ligne courante).
La moyenne mobile “lisse” les pics et creux pour révéler la tendance de fond.

Figure 8 : Moyenne mobile sur 7 jours – tendance plus lisible.
9. Part de marché par région
Objectif : calculer la contribution de chaque région au chiffre d’affaires total.
Parts de marché
SELECT
COALESCE(Store_Location, 'Inconnu') AS Store_Location,
ROUND(SUM(Total_Amount), 2) AS ventes_region,
ROUND(100.0 * SUM(Total_Amount) / SUM(SUM(Total_Amount)) OVER (), 2) AS part_pourcentage
FROM clean_sales
GROUP BY COALESCE(Store_Location, 'Inconnu')
ORDER BY ventes_region DESC;Explication :
SUM(SUM(Total_Amount)) OVER () calcule le total général des ventes.
Le ratio SUM(Total_Amount) / total_général * 100 donne la part de marché.
L’absence de PARTITION BY applique le calcul à toute la table.

Figure 9 : Parts de marché – 6 régions se partagent ~99% des ventes, 1% est non localisé.
17,25%
PHOENIX, AZ
16,56%
NEW YORK, NY
16,43%
PHILADELPHIA, PA
16,40%
HOUSTON, TX
10. Top 3 produits par catégorie
Objectif : extraire les 3 meilleurs produits de chaque catégorie.
Top 3 par catégorie (sous-requête)
SELECT * FROM (
SELECT
Product_Category,
Product_Name,
ROUND(SUM(Total_Amount), 2) AS total_ventes,
RANK() OVER (PARTITION BY Product_Category ORDER BY SUM(Total_Amount) DESC) AS rang
FROM clean_sales
GROUP BY Product_Category, Product_Name
) AS ranked
WHERE rang <= 3
ORDER BY Product_Category, rang;Explication :
La sous-requête calcule le rang de chaque produit dans sa catégorie.
La requête externe filtre pour ne garder que les rangs 1, 2 et 3.
On obtient ainsi le “podium” des produits par catégorie.

Figure 10 : Podium des produits – chaque catégorie a son top 3.
| Catégorie | 1er produit | 2e produit | 3e produit |
|---|---|---|---|
| BEAUTY | Shampoo | Toothpaste | Soap |
| CLOTHING | T-shirt | Jeans | Sneakers |
| ELECTRONICS | Smartwatch | Tablet | Smartphone |
| GROCERIES | Cereal | Coffee | Eggs |
| HOME GOODS | Towels | Curtains | Lamp |
11. Évolution mensuelle des ventes (LAG annuel)
Objectif : comparer les ventes d’un mois avec celles du même mois de l’année précédente.
LAG sur 12 mois avec CTE
WITH ventes_par_mois AS (
SELECT
strftime('%Y-%m', Sale_Date_ISO) AS mois,
ROUND(SUM(Total_Amount), 2) AS ventes_mois
FROM clean_sales
WHERE Sale_Date_ISO IS NOT NULL
GROUP BY strftime('%Y-%m', Sale_Date_ISO)
)
SELECT
mois,
ventes_mois,
LAG(ventes_mois, 12) OVER (ORDER BY mois) AS ventes_annee_precedente,
ROUND(100.0 * (ventes_mois - LAG(ventes_mois, 12) OVER (ORDER BY mois))
/ NULLIF(LAG(ventes_mois, 12) OVER (ORDER BY mois), 0), 2) AS evolution_pct
FROM ventes_par_mois
ORDER BY mois;Explication :
Une CTE (Common Table Expression) pré-calcule les ventes par mois.
LAG(ventes_mois, 12) OVER (ORDER BY mois) récupère la valeur d’il y a 12 mois.
Les 12 premiers mois ont NULL (pas d’année précédente).
NULLIF évite la division par zéro.

Figure 11 : Comparaison année sur année – croissance soutenue jusqu’en mars 2026.
+140,6%
avril 2025 vs avril 2024
+67,2%
février 2026 vs février 2025
-84,2%
avril 2026 (partiel)
12. Conclusion
Ce cas pratique a démontré la puissance des fonctions de fenêtrage SQL pour l’analyse de données métier. En une vingtaine de requêtes, nous avons :
- Calculé des cumuls et parts de marché avec
SUM() OVER - Comparé les ventes jour/jour avec
LAG() - Classé les produits par catégorie avec
RANK() - Lissé les tendances avec une moyenne mobile sur 7 jours
- Extraits les top produits par catégorie
- Analysé l’évolution annuelle avec
LAG(..., 12)
À retenir
SUM() OVER (PARTITION BY ... ORDER BY ...): cumuls par groupeLAG() / LEAD(): comparer avec lignes précédentes/suivantesRANK() / DENSE_RANK(): classements avec ou sans sautsAVG() OVER (ROWS BETWEEN N PRECEDING AND CURRENT ROW): moyennes mobilesSUM() OVER (): total général (sans PARTITION BY)- Les CTE (WITH) : indispensables pour les calculs en plusieurs étapes
Pour aller plus loin : Découvrez notre tutoriel complet sur les fonctions de fenêtrage SQL pour maîtriser toutes les window functions.
FAQ
Pourquoi avoir ajouté une colonne Sale_Date_ISO ?
SQLite ne possède pas de type DATE natif. Les dates stockées en texte sont triées alphabétiquement. MM-DD-YYYY mélange les années. La conversion en YYYY-MM-DD garantit un tri chronologique correct.
Quelle est la différence entre RANK() et DENSE_RANK() ?
RANK() saute des numéros après une égalité (ex: 1,1,3,4). DENSE_RANK() ne saute jamais (ex: 1,1,2,3). Pour un classement sans trous, utilisez DENSE_RANK().
Les mêmes requêtes fonctionnent-elles sur PostgreSQL ou MySQL ?
Globalement oui. Les window functions sont standard SQL. Seules quelques différences : strftime devient DATE_FORMAT (MySQL) ou TO_CHAR (PostgreSQL). GLOB est spécifique à SQLite.
Que signifie ROWS BETWEEN 6 PRECEDING AND CURRENT ROW ?
Cela définit une fenêtre glissante de 7 lignes : les 6 lignes précédentes + la ligne courante. La moyenne est recalculée à chaque ligne, ce qui "lisse" les variations quotidiennes.
Pourquoi utiliser une CTE pour l'évolution mensuelle ?
SQL ne permet pas de référencer un alias (mois) dans la même clause OVER. La CTE pré-calcule les ventes mensuelles, puis on applique LAG sur cette table dérivée.
Faites parler vos données
Apprenez les méthodes et les outils pour extraire de la valeur stratégique : Data Science : Le guide complet des méthodes et outils.