WITH : une table dérivée qui a un nom
La leçon précédente s’achevait sur une table dérivée imbriquée dans FROM. Voici la même requête écrite comme expression de table commune.
1
WITH category_stats AS (
SELECT cat.name AS category,
COUNT(*) AS products,
ROUND(AVG(p.price), 2) AS average
FROM products p
JOIN categories cat ON cat.id = p.category_id
GROUP BY cat.id
)
SELECT *
FROM category_stats
WHERE products >= 10
ORDER BY average DESC;
Résultat — 5 premières lignes sur 26
| category | products | average |
|---|---|---|
| Wearables | 11 | 1672.29 |
| Programming | 10 | 1585.67 |
| Tablets | 17 | 1529.45 |
| Audio | 11 | 1482.89 |
| Kitchen | 12 | 1443.21 |
Planche s’ouvre dans un onglet séparé, la base d’exemple déjà chargée et cette requête prête dans l’éditeur. Tous les exemples de la page réutilisent cet onglet, et rien ne s’exécute tant que vous n’appuyez pas sur F9.
Résultat identique, et maintenant cela se lit dans l’ordre : d’abord définir category_stats, puis l’utiliser. Le nom ne vaut que pour cette instruction — rien n’est créé, rien n’est à nettoyer.
Plusieurs étapes à la suite
Les CTE s’enchaînent, et chacune peut utiliser les précédentes : une requête imbriquée devient une suite d’étapes nommées.
2
WITH paid_orders AS (
SELECT id, customer_id, total
FROM orders
WHERE status IN ('paid', 'shipped', 'delivered')
),
per_customer AS (
SELECT customer_id,
COUNT(*) AS orders,
ROUND(SUM(total), 2) AS revenue
FROM paid_orders
GROUP BY customer_id
)
SELECT c.first_name || ' ' || c.last_name AS customer,
c.country,
pc.orders,
pc.revenue
FROM per_customer pc
JOIN customers c ON c.id = pc.customer_id
ORDER BY pc.revenue DESC
LIMIT 20;
Résultat — 5 premières lignes sur 20
| customer | country | orders | revenue |
|---|---|---|---|
| Sofia Patel | BR | 10 | 70324.42 |
| Zara Novak | JP | 5 | 70149.53 |
| Elena Rossi | KR | 10 | 58867.23 |
| Ines Choi | CA | 6 | 58455.76 |
| Noah Costa | ES | 4 | 57721.52 |
Trois étapes nommées : restreindre les commandes, résumer par client, rattacher le nom du client. En sous-requêtes imbriquées, la même requête se lirait de l’intérieur vers l’extérieur ; écrite ainsi, c’est une liste d’étapes dans l’ordre où elles se produisent. Quand une requête devient difficile à suivre, la découper en CTE est en général la première chose à essayer.
Les fonctions de fenêtrage : agréger sans fusionner
GROUP BY transforme plusieurs lignes en une. Une fonction de fenêtrage calcule sur un ensemble de lignes et conserve chaque ligne.
3
SELECT p.name,
p.price,
RANK() OVER (ORDER BY p.price DESC) AS price_rank,
DENSE_RANK() OVER (ORDER BY p.price DESC) AS dense_price_rank,
ROW_NUMBER() OVER (ORDER BY p.price DESC) AS row_no
FROM products p
ORDER BY p.price DESC
LIMIT 20;
Résultat — 5 premières lignes sur 20
| name | price | price_rank | dense_price_rank | row_no |
|---|---|---|---|---|
| Glide GPS Watch Pro | 2484.4 | 1 | 1 | 1 |
| Terra 3D Puzzle | 2481.77 | 2 | 2 | 2 |
| Aria Castle Kit | 2479.41 | 3 | 3 | 3 |
| Aria LED Strip Pro | 2463.41 | 4 | 4 | 4 |
| Nova Brief Answers | 2445.49 | 5 | 5 | 5 |
OVER (ORDER BY p.price DESC) est la fenêtre : les lignes que ce calcul regarde, et dans quel ordre. Trois fonctions, trois réponses différentes en cas d’égalité de prix :
ROW_NUMBER()— 1, 2, 3, 4. Toujours distincts, égalités départagées arbitrairement.RANK()— 1, 2, 2, 4. Les ex æquo partagent un rang et la valeur suivante saute.DENSE_RANK()— 1, 2, 2, 3. Les ex æquo partagent un rang et rien n’est sauté.
Le choix dépend de la question. « Donne-moi exactement dix lignes » veut ROW_NUMBER ; « qui est deuxième » veut l’un des rangs.
Top N par groupe
Sans fonctions de fenêtrage, il faudrait une sous-requête corrélée comptant combien de produits de la catégorie sont plus chers. PARTITION BY recommence la numérotation pour chaque groupe.
4
WITH ranked AS (
SELECT cat.name AS category,
p.name AS product,
p.price,
ROW_NUMBER() OVER (PARTITION BY cat.id ORDER BY p.price DESC) AS rank_in_category
FROM products p
JOIN categories cat ON cat.id = p.category_id
)
SELECT category, product, price
FROM ranked
WHERE rank_in_category <= 3
ORDER BY category, rank_in_category;
Résultat — 5 premières lignes sur 81
| category | product | price |
|---|---|---|
| Audio | Atlas Earbuds | 2334.28 |
| Audio | Kite Speaker | 2331.15 |
| Audio | Quill Speaker | 2177.84 |
| Bags | Pulse Crossbody Mini | 2377 |
| Bags | Zen Crossbody | 2295.35 |
Cumuls
Ajoutez un ORDER BY à la fenêtre et chaque ligne voit tout ce qui précède, elle comprise.
5
WITH monthly AS (
SELECT substr(ordered_at, 1, 7) AS month,
ROUND(SUM(total), 2) AS revenue
FROM orders
WHERE status IN ('paid', 'shipped', 'delivered')
AND ordered_at >= '2025-01-01'
GROUP BY month
)
SELECT month,
revenue,
ROUND(SUM(revenue) OVER (ORDER BY month), 2) AS running_total
FROM monthly
ORDER BY month;
Résultat — 5 premières lignes sur 12
| month | revenue | running_total |
|---|---|---|
| 2025-01 | 311707.73 | 311707.73 |
| 2025-02 | 350775.62 | 662483.35 |
| 2025-03 | 351184.96 | 1013668.31 |
| 2025-04 | 374465.94 | 1388134.25 |
| 2025-05 | 381541.09 | 1769675.34 |
La CTE produit une ligne par mois ; SUM(revenue) OVER (ORDER BY month) accumule le long de cette liste. La même astuce donne des compteurs cumulés, des pourcentages cumulés et des courbes de burn-down — tout ce dont la réponse pour une ligne dépend des lignes précédentes.
Comparer à la ligne précédente
6
WITH monthly AS (
SELECT substr(ordered_at, 1, 7) AS month,
ROUND(SUM(total), 2) AS revenue
FROM orders
WHERE status IN ('paid', 'shipped', 'delivered')
AND ordered_at >= '2025-01-01'
GROUP BY month
)
SELECT month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS previous_month,
ROUND(revenue - LAG(revenue) OVER (ORDER BY month), 2) AS change
FROM monthly
ORDER BY month;
Résultat — 5 premières lignes sur 12
| month | revenue | previous_month | change |
|---|---|---|---|
| 2025-01 | 311707.73 | NULL | NULL |
| 2025-02 | 350775.62 | 311707.73 | 39067.89 |
| 2025-03 | 351184.96 | 350775.62 | 409.34 |
| 2025-04 | 374465.94 | 351184.96 | 23280.98 |
| 2025-05 | 381541.09 | 374465.94 | 7075.15 |
LAG(revenue) remonte d’une ligne dans la fenêtre ; LEAD avance d’une. L’évolution d’un mois sur l’autre, qui demanderait autrement de joindre la table à elle-même avec un décalage, tient maintenant en une ligne. Le previous_month de la première ligne est NULL — il n’y a rien avant — donc l’évolution est NULL aussi, ce qui est la réponse honnête plutôt qu’un zéro trompeur.
Prise en charge
Les fonctions de fenêtrage sont standard et disponibles partout où vous les rencontrerez : PostgreSQL, SQL Server et Oracle les ont depuis de nombreuses années, SQLite depuis 3.25 (2018) et MySQL depuis 8.0 (2018). MariaDB les a à partir de 10.2. MySQL 5.7 est la seule version que vous risquez encore de croiser sans elles.
Ce que vous savez faire maintenant
Vous savez lire les données dans à peu près n’importe quelle forme utile. Les deux dernières leçons portent sur l’autre moitié du travail : modifier les données sans danger, et découvrir pourquoi une requête est lente.