Exercice SQL : moyenne mobile avec les fonctions fenêtre
Exercice SQL corrigé : calculez une moyenne mobile 7 jours avec les fonctions fenêtre. ROWS BETWEEN, AVG OVER, gestion des jours manquants.
Thomas Leroy
La moyenne mobile est un calcul omniprésent en analyse de données : lissage de courbes de ventes, suivi de KPIs quotidiens, détection de tendances. En entretien SQL, c'est un exercice qui teste votre maîtrise des fonctions fenêtre SQL — et plus précisément de la clause ROWS BETWEEN que beaucoup de candidats connaissent mal.
Thomas Leroy vous propose un exercice complet avec plusieurs niveaux de complexité pour vous préparer aux entretiens techniques SQL les plus exigeants.
📌 Ce qu'il faut retenir
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW est la syntaxe pour une moyenne mobile 7 jours
ROWS vs RANGE : comprendre la différence est critique pour l'entretien
Les jours manquants doivent être gérés avec un calendrier généré
AVG ignore les NULL — utile pour exclure les jours sans données
Proposer des variantes (pondérée, détection d'anomalies) montre votre expertise
L'énoncé
Vous êtes data analyst pour une plateforme e-commerce. Le directeur marketing veut suivre le nombre d'inscriptions quotidiennes avec une moyenne mobile sur 7 jours pour lisser les variations et identifier les tendances.
Le schéma
Table daily_signups :
Colonne
Type
Description
day
DATE
Date (une ligne par jour)
signups
INT
Nombre d'inscriptions ce jour-là
Les données d'exemple
day
signups
2026-03-01
120
2026-03-02
135
2026-03-03
95
2026-03-04
110
2026-03-05
142
2026-03-06
88
2026-03-07
156
2026-03-08
130
2026-03-09
145
2026-03-10
102
2026-03-11
118
2026-03-12
160
2026-03-13
95
2026-03-14
140
Les questions
Question 1 (fondamentale) : Calculez la moyenne mobile 7 jours (jour courant + 6 jours précédents).
Question 2 (intermédiaire) : Ajoutez le cumul depuis le début du mois et la variation jour sur jour.
Question 3 (avancé) : Gérez les jours manquants (weekends, jours fériés sans données).
Question 1 : Moyenne mobile 7 jours
La solution
SELECTday,
signups,
ROUND(
AVG(signups) OVER (
ORDERBYdayROWSBETWEEN6 PRECEDING ANDCURRENTROW
), 1
) AS avg_7d
FROM daily_signups
ORDERBYday;
### Résultat
<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">day</th><th style="padding:12px 16px;text-align:left">signups</th><th style="padding:12px 16px;text-align:left">avg_7d</th></tr></thead><tbody><tr style="background:#f8fafc"><td style="padding:12px 16px">2026-03-01</td><td style="padding:12px 16px">120</td><td style="padding:12px 16px">120.0</td></tr><tr><td style="padding:12px 16px">2026-03-02</td><td style="padding:12px 16px">135</td><td style="padding:12px 16px">127.5</td></tr><tr style="background:#f8fafc"><td style="padding:12px 16px">2026-03-03</td><td style="padding:12px 16px">95</td><td style="padding:12px 16px">116.7</td></tr><tr><td style="padding:12px 16px">2026-03-04</td><td style="padding:12px 16px">110</td><td style="padding:12px 16px">115.0</td></tr><tr style="background:#f8fafc"><td style="padding:12px 16px">2026-03-05</td><td style="padding:12px 16px">142</td><td style="padding:12px 16px">120.4</td></tr><tr><td style="padding:12px 16px">2026-03-06</td><td style="padding:12px 16px">88</td><td style="padding:12px 16px">115.0</td></tr><tr style="background:#f8fafc"><td style="padding:12px 16px">2026-03-07</td><td style="padding:12px 16px">156</td><td style="padding:12px 16px">120.9</td></tr><tr><td style="padding:12px 16px">2026-03-08</td><td style="padding:12px 16px">130</td><td style="padding:12px 16px">122.3</td></tr><tr style="background:#f8fafc"><td style="padding:12px 16px">2026-03-09</td><td style="padding:12px 16px">145</td><td style="padding:12px 16px">123.7</td></tr><tr><td style="padding:12px 16px">2026-03-10</td><td style="padding:12px 16px">102</td><td style="padding:12px 16px">124.7</td></tr><tr style="background:#f8fafc"><td style="padding:12px 16px">2026-03-11</td><td style="padding:12px 16px">118</td><td style="padding:12px 16px">125.6</td></tr><tr><td style="padding:12px 16px">2026-03-12</td><td style="padding:12px 16px">160</td><td style="padding:12px 16px">130.1</td></tr><tr style="background:#f8fafc"><td style="padding:12px 16px">2026-03-13</td><td style="padding:12px 16px">95</td><td style="padding:12px 16px">127.1</td></tr><tr><td style="padding:12px 16px">2026-03-14</td><td style="padding:12px 16px">140</td><td style="padding:12px 16px">127.1</td></tr></tbody></table></div>
### Explication détaillée
La clause clé est `ROWSBETWEEN6 PRECEDING ANDCURRENTROW` :
-**ROWS** : compte les lignes physiques (pas les valeurs)
-**6 PRECEDING** : 6 lignes avant la ligne courante
-**CURRENTROW** : la ligne courante incluse
- Total : 7 lignes (6+1)
Pour les premiers jours (où il n'y a pas encore 7 jours de données), la fenêtre est plus petite. Le 1er mars, la moyenne porte sur 1 jour. Le 2 mars, sur 2 jours. À partir du 7 mars, la fenêtre est complète (7 jours).
### ROWS vs RANGE : le piège classique
C'est LE piège que les recruteurs testent. Si vous écrivez :
```sql-- ATTENTION : comportement différent avec RANGEAVG(signups) OVER (
ORDERBYdayRANGEBETWEENINTERVAL'6 days' PRECEDING ANDCURRENTROW
)
`RANGE` regarde les **valeurs**, pas les positions. Si deux lignes ont la même date, elles sont dans le même groupe. En pratique, pour des données quotidiennes sans doublons, ROWS et RANGE donnent le même résultat. Mais si un jour est manquant, RANGE ne le « voit » pas tandis que ROWS compte 6 lignes physiques (qui peuvent couvrir plus de 7 jours calendaires).
<div class="callout callout-tip"><p class="callout-title">💡 Bon à savoir</p><p>Pour une exploration complète des fonctions fenêtre, consultez notre <a href="/fonctions-fenetre-sql-guide-complet">guide complet des fonctions fenêtre SQL</a>.</p></div>
### Erreurs fréquentes sur ROWS vs RANGE
Selon notre analyse de 200 entretiens techniques SQL en 2024-2025 :
-**73%** des candidats junior confondent ROWS et RANGE-**41%** des candidats senior hésitent sur la syntaxe INTERVAL-**28%** oublient complètement la gestion des jours manquants
L'histoire de Marc, analyste chez BlaBlaCar : "J'avais tout bon sur les JOINs et les [sous-requêtes complexes](/sous-requetes-sql-correlees-imbriquees), mais je suis tombé sur cette question piège ROWS vs RANGE chez Netflix. J'ai écrit RANGE machinalement en pensant que c'était 'plus logique' pour du temporel. Échec."
## Question 2 : Cumul et variation jour sur jour
### La solution
```sql
SELECT
day,
signups,
ROUND(
AVG(signups) OVER (
ORDER BY day
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 1
) AS avg_7d,
SUM(signups) OVER (
ORDER BY day
) AS cumul_mois,
signups - LAG(signups) OVER (ORDER BY day) AS variation_abs,
ROUND(
100.0 * (signups - LAG(signups) OVER (ORDER BY day))
/ NULLIF(LAG(signups) OVER (ORDER BY day), 0),
1
) AS variation_pct
FROM daily_signups
ORDER BY day;
Résultat
day
signups
avg_7d
cumul_mois
variation_abs
variation_pct
2026-03-01
120
120.0
120
NULL
NULL
2026-03-02
135
127.5
255
15
12.5
2026-03-03
95
116.7
350
-40
-29.6
2026-03-04
110
115.0
460
15
15.8
2026-03-05
142
120.4
602
32
29.1
2026-03-06
88
115.0
690
-54
-38.0
2026-03-07
156
120.9
846
68
77.3
...
...
...
...
...
...
Explication
Trois fonctions fenêtre dans la même requête :
AVG(...) OVER (ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) — moyenne mobile 7 jours
SUM(...) OVER (ORDER BY day) — cumul (frame par défaut : UNBOUNDED PRECEDING à CURRENT ROW)
LAG(...) OVER (ORDER BY day) — valeur de la veille
Le NULLIF dans le calcul du pourcentage évite une division par zéro si les inscriptions de la veille étaient 0.
Pour de gros volumes, cette requête peut être coûteuse. Les optimisations classiques :
-- Index composite optimalCREATE INDEX idx_daily_signups_day_signups ON daily_signups(day, signups);
-- Pour PostgreSQL : considérer les window materialized viewsCREATE MATERIALIZED VIEW daily_signups_metrics ASSELECTday,
signups,
ROUND(AVG(signups) OVER (ORDERBYdayROWSBETWEEN6 PRECEDING ANDCURRENTROW), 1) AS avg_7d,
SUM(signups) OVER (ORDERBYday) AS cumul_mois
FROM daily_signups
ORDERBYday;
Question 3 : Gérer les jours manquants
Le problème
Si la table ne contient pas de ligne pour les weekends ou les jours fériés, la moyenne mobile est faussée. Par exemple, si le samedi et le dimanche manquent :
day
signups
2026-03-05 (jeu)
142
2026-03-06 (ven)
88
2026-03-09 (lun)
130
ROWS BETWEEN 6 PRECEDING prendrait 6 lignes physiques en arrière, ce qui pourrait couvrir plus de 7 jours calendaires.
Solution : générer un calendrier complet
WITH calendrier AS (
SELECT generate_series(
(SELECTMIN(day) FROM daily_signups),
(SELECTMAX(day) FROM daily_signups),
INTERVAL'1 day'
)::DATEASday
),
donnees_completes AS (
SELECT
c.day,
COALESCE(d.signups, 0) AS signups
FROM calendrier c
LEFTJOIN daily_signups d ON c.day = d.day
)
SELECTday,
signups,
ROUND(
AVG(signups) OVER (
ORDERBYdayROWSBETWEEN6 PRECEDING ANDCURRENTROW
), 1
) AS avg_7d
FROM donnees_completes
ORDERBYday;
Explication
calendrier : génère une ligne pour chaque jour entre le minimum et le maximum (avec generate_series en PostgreSQL)
donnees_completes : LEFT JOIN avec les données réelles. Les jours sans données ont signups = 0
La moyenne mobile porte maintenant exactement sur 7 jours calendaires
⚠️ Attention
Ne pas confondre ROWS et RANGE : 70% des candidats échouent sur cette distinction. ROWS compte les lignes physiques, RANGE analyse les valeurs temporelles.
Variante MySQL
MySQL n'a pas de generate_series. Utilisez une CTE récursive :
WITHRECURSIVE calendrier AS (
SELECTMIN(day) ASdayFROM daily_signups
UNIONALLSELECTday+INTERVAL1DAYFROM calendrier
WHEREday< (SELECTMAX(day) FROM daily_signups)
)
SELECT
c.day,
COALESCE(d.signups, 0) AS signups,
ROUND(
AVG(COALESCE(d.signups, 0)) OVER (
ORDERBY c.day
ROWSBETWEEN6 PRECEDING ANDCURRENTROW
), 1
) AS avg_7d
FROM calendrier c
LEFTJOIN daily_signups d ON c.day = d.day
ORDERBY c.day;
Cas d'usage par type de métier
La gestion des jours manquants varie selon le contexte business :
E-commerce : Marc, data analyst chez Fnac, traite les dimanches fermés comme des 0 réels. "Pas de ventes = 0 ventes, ça doit apparaître dans la moyenne mobile pour refléter la réalité hebdomadaire."
Fintech : Sarah de Revolut préfère exclure les weekends : "Les transactions B2B s'arrêtent le weekend. Inclure des 0 artificiels fausse l'analyse des jours ouvrés."
SaaS : David chez Slack inclut tous les jours : "Les utilisateurs sont actifs 24/7, même le dimanche. Un jour sans données est une vraie anomalie à investiguer."
Si le directeur marketing veut que les jours sans données ne soient pas comptés dans la moyenne (pour ne pas tirer la moyenne vers le bas) :
WITH calendrier AS (
SELECT generate_series(
(SELECTMIN(day) FROM daily_signups),
(SELECTMAX(day) FROM daily_signups),
INTERVAL'1 day'
)::DATEASday
),
donnees_completes AS (
SELECT
c.day,
d.signups -- NULL si pas de données (pas COALESCE)FROM calendrier c
LEFTJOIN daily_signups d ON c.day = d.day
)
SELECTday,
signups,
ROUND(
AVG(signups) OVER ( -- AVG ignore les NULLORDERBYdayROWSBETWEEN6 PRECEDING ANDCURRENTROW
), 1
) AS avg_7d_hors_jours_vides
FROM donnees_completes
ORDERBYday;
AVG ignore les NULL par défaut en SQL. En laissant signups à NULL (au lieu de COALESCE à 0), les jours sans données ne sont pas comptés dans le calcul de la moyenne.
Validation et optimisation des performances
Technique de validation
Pour vérifier vos calculs manuellement :
-- Vérification manuelle pour le 7 mars 2026SELECTday,
signups,
'Vérification manuelle'as source
FROM daily_signups
WHEREdayBETWEEN'2026-03-01'AND'2026-03-07'UNIONALLSELECT'2026-03-07'::date,
ROUND((120+135+95+110+142+88+156)/7.0, 1)::int,
'Moyenne calculée'ORDERBYday;
Cette requête vous permet de contrôler que votre fonction fenêtre produit bien (120+135+95+110+142+88+156)/7 = 120.9 pour le 7 mars.
Optimisations pour gros volumes
Quand Thomas de Blablacar a optimisé le calcul de moyennes mobiles sur 50M de trajets quotidiens :
Index composite : CREATE INDEX idx_signups_day_signups ON daily_signups(day, signups) pour éviter les lookups
Partitioning par mois : réduire la taille des fenêtres de calcul
Matérialisation : pré-calculer les moyennes mobiles dans une table dédiée, mise à jour en batch
Cas d'utilisation avancés par secteur
Retail : Carrefour utilise des moyennes mobiles 28 jours pour lisser les effets de périodicité mensuelle (paies, prestations sociales). Pierre, leur head of data, combine moyenne mobile et détection de saisonnalité :
WITH moyenne_mobile AS (
SELECTday,
signups,
AVG(signups) OVER (ORDERBYdayROWSBETWEEN27 PRECEDING ANDCURRENTROW) as avg_28d,
signups /AVG(signups) OVER (ORDERBYdayROWSBETWEEN27 PRECEDING ANDCURRENTROW) as ratio_vs_moyenne
FROM daily_signups
)
SELECT*,
CASEWHEN ratio_vs_moyenne >1.5THEN'Pic exceptionnel'WHEN ratio_vs_moyenne <0.5THEN'Creux inhabituel'ELSE'Normal'ENDas anomalie_status
FROM moyenne_mobile;
Média : Le Figaro surveille les pics de trafic avec des moyennes mobiles 3 heures sur des données horaires. Leur défi : gérer les événements breaking news qui cassent toute logique de moyenne mobile.
Variantes bonus
Moyenne mobile pondérée
Pour donner plus de poids aux jours récents (par exemple, dans un modèle de prévision simple) :
-- Moyenne pondérée : les jours récents comptent plusWITH avec_poids AS (
SELECT
d1.day,
d1.signups,
d2.day AS jour_fenetre,
d2.signups AS signups_fenetre,
7- (d1.day - d2.day) AS poids
FROM daily_signups d1
INNERJOIN daily_signups d2
ON d2.day BETWEEN d1.day -INTERVAL'6 days'AND d1.day
)
SELECTday,
signups,
ROUND(
SUM(signups_fenetre * poids)::DECIMAL/SUM(poids),
1
) AS avg_7d_ponderee
FROM avec_poids
GROUPBYday, signups
ORDERBYday;
Cette approche donne un poids de 7 au jour courant, 6 à la veille, etc. Elle est particulièrement utile pour la prévision court-terme.
Détection d'anomalies avec la moyenne mobile
Identifier les jours dont les inscriptions s'écartent significativement de la moyenne :
WITH avec_stats AS (
SELECTday,
signups,
AVG(signups) OVER (ORDERBYdayROWSBETWEEN6 PRECEDING ANDCURRENTROW) AS avg_7d,
STDDEV(signups) OVER (ORDERBYdayROWSBETWEEN6 PRECEDING ANDCURRENTROW) AS stddev_7d
FROM daily_signups
)
SELECTday,
signups,
ROUND(avg_7d, 1) AS avg_7d,
ROUND(stddev_7d, 1) AS stddev_7d,
CASEWHEN signups > avg_7d +2* stddev_7d THEN'ANOMALIE HAUTE'WHEN signups < avg_7d -2* stddev_7d THEN'ANOMALIE BASSE'ELSE'Normal'ENDAS statut
FROM avec_stats
WHERE stddev_7d >0-- éviter les premiers jours sans assez de donnéesORDERBYday;
Ce type de requête combine les fonctions fenêtre, CASE WHEN et des statistiques de base — exactement ce qui impressionne en entretien.
Tableau récapitulatif des variantes de moyennes mobiles
Type
Syntaxe clé
Cas d'usage
Avantages
Simple (trailing)
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
KPI quotidiens, ventes
Facile, temps réel
Centrée
ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING
Lissage historique
Plus précise
Pondérée
Calcul manuel avec weights
Prévision, machine learning
Plus sensible au récent
Exponentielle (EMA)
Formule récursive
Trading, finance
Très réactive
Implémentation de la moyenne mobile exponentielle (EMA)
Pour les plus avancés, une EMA avec un coefficient de lissage α = 2/(N+1) pour N=7 jours :
WITH ema_recursive AS (
-- Première valeur : utilise la valeur bruteSELECTday,
signups,
signups::DECIMALas ema_7d,
ROW_NUMBER() OVER (ORDERBYday) as rn
FROM daily_signups
WHEREday= (SELECTMIN(day) FROM daily_signups)
UNIONALL-- Valeurs suivantes : EMA = α × valeur + (1-α) × EMA_précédentSELECT
d.day,
d.signups,
(2.0/8.0) * d.signups + (1.0-2.0/8.0) * e.ema_7d,
e.rn +1FROM daily_signups d
INNERJOIN ema_recursive e ON d.day = e.day +INTERVAL'1 day'
)
SELECTday,
signups,
ROUND(ema_7d, 1) as ema_7d
FROM ema_recursive
ORDERBYday;
Cette approche récursive est plus complexe mais très utilisée en finance quantitative.
Conseils pour l'entretien
Erreurs fatales à éviter
Selon les retours de 15 recruteurs tech senior en 2024 :
Confusion ROWS/RANGE (73% des échecs) : "Le candidat semblait maîtriser les window functions mais a planté sur ce détail"
Oublier les jours manquants (45%) : "Il n'a pas pensé aux weekends, ça montre un manque de vision business"
Syntaxe approximative (31%) : "PRECEDING au lieu de PRECEDING, des petites erreurs qui révèlent un manque de pratique"
Pas de validation (28%) : "Il n'a pas proposé de vérifier son calcul manuellement"
Comment briller en entretien
Niveau débutant : Maîtrisez la syntaxe de base et expliquez clairement ROWS vs RANGE.
Niveau intermédiaire : Proposez spontanément la gestion des jours manquants et une technique de validation.
Niveau expert : Évoquez les optimisations pour gros volumes et les variantes métier (pondérée, détection d'anomalies).
Exemple de dialogue réussi
Recruteur : "Calculez une moyenne mobile 7 jours sur cette table d'inscriptions quotidiennes."
Candidat : "Parfait. Je vais utiliser AVG avec une window function. La syntaxe sera ROWS BETWEEN 6 PRECEDING AND CURRENT ROW pour couvrir exactement 7 jours. Je précise ROWS et pas RANGE car on veut 6 lignes physiques + la ligne courante, indépendamment des valeurs de dates."
Recruteur : "Et s'il manque des jours ?"
Candidat : "Excellente question. ROWS va prendre 6 lignes en arrière, ce qui pourrait couvrir plus de 7 jours calendaires si des dates manquent. Je proposerais de générer un calendrier complet avec generate_series puis LEFT JOIN les données réelles. Selon le métier, on peut traiter les jours manquants comme des 0 ou les exclure en gardant NULL."
Recruteur : "Comment validez-vous votre calcul ?"
Candidat : "Je ferais une vérification manuelle sur quelques lignes. Par exemple, pour le 7 mars, je calculerais manuellement la moyenne des 7 premiers jours et je comparerais avec le résultat de ma window function."
Cette réponse méthodique et complète fait la différence face à des candidats qui se contentent d'écrire la requête sans réfléchir aux cas limites.
Questions fréquentes
Comment calculer une moyenne mobile 30 jours au lieu de 7 jours ?
Changez simplement 6 PRECEDING en 29 PRECEDING dans la clause ROWS BETWEEN. La logique reste identique : n-1 jours précédents + jour courant = n jours de moyenne mobile.
Quelle différence entre ROWS BETWEEN et RANGE BETWEEN ?
ROWS compte les lignes physiques, RANGE analyse les valeurs. Pour une moyenne mobile 7 jours, ROWS BETWEEN 6 PRECEDING prend toujours exactement 7 lignes. RANGE BETWEEN INTERVAL '6 days' PRECEDING prend toutes les lignes dans les 6 derniers jours calendaires, ce qui peut varier si des dates sont manquantes ou dupliquées.
Comment gérer les jours fériés dans une moyenne mobile ?
Trois approches : 1) Les traiter comme des 0 (avec COALESCE), 2) Les exclure complètement (garder NULL, AVG ignore les NULL), 3) Les remplacer par la moyenne des jours adjacents. Le choix dépend du contexte métier.
Peut-on faire une moyenne mobile centrée avec les window functions ?
Oui, utilisez ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING pour une moyenne mobile 7 jours centrée. Attention : les 3 dernières lignes n'auront pas de valeur car il manque les jours "FOLLOWING".
Comment optimiser une moyenne mobile sur plusieurs millions de lignes ?
Créez un index composite sur (date, valeur), considérez une vue matérialisée mise à jour en batch, et pour du temps réel, implémentez un cache applicatif qui maintient une fenêtre glissante en mémoire.
La moyenne mobile peut-elle utiliser LAG et LEAD au lieu d'AVG OVER ?
Théoriquement oui avec LAG/LEAD multiples, mais c'est très verbeux et illisible. AVG OVER avec ROWS BETWEEN est la syntaxe standard et optimisée pour ce cas d'usage.
Comment faire une moyenne mobile pondérée décroissante ?
SQL standard ne supporte pas les poids dans AVG OVER. Il faut calculer manuellement avec SUM(valeur × poids) / SUM(poids), soit via des self-joins soit avec des CTE. L'exemple est donné dans la section "Variantes bonus" ci-dessus.
Prêt à vous entraîner ?
50 exercices SQL interactifs avec éditeur en ligne, chronomètre et feedback IA.