Comment j’ai apprivoisé les agrégations SQL (et arrêté de maudire mes rapports)
Quand on débute avec SQL, l’essentiel c’est que la requête “marche”. On a le bon résultat et on s’arrête là. Le problème, c’est que ce résultat peut mettre 30 secondes, 3 minutes ou une éternité. Et en production, ces secondes coûtent cher : dashboards lents, jobs bloqués, utilisateurs qui pensent que “la data, ça rame”.
À force de me faire piéger par des requêtes trop lourdes, j’ai fini par comprendre que ce n’était pas seulement une question de syntaxe, mais une question de conversation avec le moteur SQL. Est-ce que je lui explique clairement ce que je veux ? Ou est-ce que je lui lance un charabia ambigu qui l’oblige à faire trois fois le tour de la table pour deviner mon intention ?
Voilà dix situations où j’ai vu mes requêtes s’effondrer, et comment j’ai appris à les réparer en comprenant ce qui se passait derrière.
1) Les sous-requêtes imbriquées qui deviennent des labyrinthes
Quand j’enchaîne les sous-requêtes les unes dans les autres, j’obtiens une requête illisible, difficile à maintenir, et surtout difficile à optimiser pour le moteur. Le planificateur voit un bloc compact qu’il traite comme une boîte noire. Résultat : difficile de pousser les filtres plus tôt ou de choisir le bon index. Et moi, je dois relire dix parenthèses imbriquées pour comprendre mon propre travail.
SELECT user_id, AVG(total)
FROM (
SELECT *
FROM orders
WHERE created_at > now() - interval '30 days'
) recent
GROUP BY user_id
HAVING AVG(total) > 100;
La logique est correcte, mais rien n’est réutilisable, rien n’est explicite.
🚀 Maîtrisez SQL pour exploiter pleinement vos données BigQuery !
Découvrez nos formations BigQuery adaptées à tous les niveaux, du débutant à l’expert. Apprenez à interroger, analyser et optimiser vos données avec SQL dans BigQuery, et exploitez toute la puissance du cloud pour des analyses avancées. Du niveau 1, où vous explorerez et visualiserez vos données avec BigQuery et Looker Studio, au niveau 2, qui vous permettra de maîtriser les requêtes SQL pour trier, filtrer et structurer efficacement vos données, jusqu’au niveau 3, dédié aux techniques avancées d’optimisation et d’automatisation. Que vous soyez analyste, data scientist ou développeur, ces formations vous permettront de gagner en autonomie et en efficacité dans le traitement de vos données.🔍📊
Avec une CTE (Common Table Expression), j’écris la même logique, mais je la découpe en étapes lisibles :
WITH recent_orders AS (
SELECT *
FROM orders
WHERE created_at > now() - interval '30 days'
),
average_by_user AS (
SELECT user_id, AVG(total) AS avg_total
FROM recent_orders
GROUP BY user_id
)
SELECT *
FROM average_by_user
WHERE avg_total > 100;
Chaque bloc est clair, réutilisable, et je peux analyser son plan d’exécution individuellement. Depuis PostgreSQL 12, je peux même indiquer si je veux que la CTE soit matérialisée (stockée une fois pour toutes) ou recalculée à la volée. En clair : je reprends le contrôle.
Compatibilité : PostgreSQL (avec MATERIALIZED/NOT MATERIALIZED
), SQL Server, BigQuery, Snowflake, Oracle, Databricks.
2) La sous-requête corrélée qui se répète pour chaque ligne
Un grand classique : je veux la dernière commande de chaque utilisateur. J’écris une sous-requête corrélée qui va chercher la commande la plus récente… mais pour chaque utilisateur, la base relance le même calcul. À petite échelle ça marche. À grande échelle, c’est une machine à générer du temps perdu.
SELECT u.name, (
SELECT o.id
FROM orders o
WHERE o.user_id = u.id
ORDER BY o.created_at DESC
LIMIT 1
) AS latest_order_id
FROM users u;
Le moteur exécute la sous-requête autant de fois qu’il y a d’utilisateurs. Même avec un index, c’est un cauchemar quand on parle de millions de lignes.
Avec LATERAL
(ou APPLY
dans SQL Server/Oracle), j’exprime la même logique comme un vrai JOIN, et le moteur peut optimiser globalement.
SELECT u.name, o.id AS latest_order_id
FROM users u
JOIN LATERAL (
SELECT id
FROM orders
WHERE user_id = u.id
ORDER BY created_at DESC
LIMIT 1
) o ON true;
Cette fois, la base sait qu’elle doit utiliser l’index (user_id, created_at DESC)
pour parcourir directement les commandes dans le bon ordre et s’arrêter à la première. Je passe d’un traitement quadratique à un parcours linéaire et efficace.
Compatibilité : PostgreSQL (LATERAL
), SQL Server / Oracle (CROSS APPLY
, OUTER APPLY
), BigQuery et Snowflake (via fenêtres + QUALIFY
).
3) Les unions qui trient pour rien
Par réflexe, j’ai longtemps utilisé UNION
même quand je n’avais pas besoin d’unicité. Mauvais réflexe : la base trie et déduplique par défaut, ce qui veut dire tri, hash, et mémoire utilisée à perte.
SELECT name FROM employees
UNION
SELECT name FROM contractors;
Si je veux juste concaténer deux ensembles, inutile de payer pour un tri.
SELECT name FROM employees
UNION ALL
SELECT name FROM contractors;
Avec UNION ALL
, la base enchaîne les résultats sans se poser de questions. Sur des millions de lignes, c’est la différence entre un traitement qui sature le disque et un traitement qui passe en mémoire sans douleur.
Compatibilité : Tous.
4) Le “dernier par groupe” qui parcourt trop
Quand je veux la dernière commande par utilisateur, j’ai tendance à sortir ROW_NUMBER()
avec une fenêtre. Ça marche, mais le moteur calcule un rang pour toutes les lignes avant de filtrer.
SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM orders
) t
WHERE rn = 1;
La solution la plus élégante côté PostgreSQL, c’est DISTINCT ON
:
SELECT DISTINCT ON (user_id) *
FROM orders
ORDER BY user_id, created_at DESC;
Le moteur peut s’appuyer sur un index (user_id, created_at DESC)
et sortir directement la bonne ligne sans classer toutes les autres. Plus concis, plus rapide.
Dans BigQuery ou Snowflake, j’utilise QUALIFY
avec une fenêtre :
SELECT *
FROM orders
QUALIFY ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) = 1;
Compatibilité : PostgreSQL (DISTINCT ON
), tous les autres avec fenêtres (ROW_NUMBER
) + filtre (ou QUALIFY
).
5) Le petit référentiel qui mérite mieux qu’une vraie table
Pour mapper trois rôles à trois ID, beaucoup créent une table dédiée. Mais c’est une lourdeur administrative pour un usage minuscule.
SELECT u.name, c.level_name
FROM users u
JOIN clearance_levels c ON u.clearance_id = c.id;
Avec VALUES
, je définis mon référentiel directement dans la requête :
SELECT u.name, r.role_name
FROM users u
JOIN (VALUES
(1, 'Admin'),
(2, 'Editor'),
(3, 'Viewer')
) AS r(role_id, role_name)
ON u.role_id = r.role_id;
C’est exécuté en mémoire, zéro I/O disque. Idéal pour des jeux fixes, des tests, ou des rapports ponctuels.
Compatibilité : PostgreSQL, SQL Server, Oracle, Snowflake (VALUES
), BigQuery (via UNNEST([STRUCT(...)])
).
6) Les GROUP BY
avec chiffres qui explosent en silence
GROUP BY 1,2
est séduisant parce que c’est court. Mais c’est une bombe à retardement. Change l’ordre des colonnes dans la SELECT
et tu changes la logique sans erreur visible.
SELECT department, country, COUNT(*)
FROM employees
GROUP BY 1,2;
La version claire :
SELECT department, country, COUNT(*)
FROM employees
GROUP BY department, country;
Tout le monde comprend, personne ne se fait piéger par un décalage de positions.
Compatibilité : Tous.
7) Le JOIN qui gonfle tout avant d’agréger
Si je joins une table users
avec une table orders
et que j’agrège ensuite, je me retrouve à manipuler un volume gigantesque avant la réduction. C’est comme trier les invités d’une fête après avoir invité tout l’annuaire.
SELECT u.name, SUM(o.amount) AS total
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.name;
En agrégeant d’abord, je réduis le jeu de données avant le JOIN.
WITH user_totals AS (
SELECT user_id, SUM(amount) AS total
FROM orders
GROUP BY user_id
)
SELECT u.name, ut.total
FROM users u
JOIN user_totals ut ON ut.user_id = u.id;
Le moteur n’a plus qu’à faire un JOIN sur une table compacte. Moins de mémoire, moins de tris, moins de temps perdu.
Compatibilité : Tous.
8) Les CASE à rallonge dans les agrégats
Compter uniquement les utilisateurs actifs avec un CASE
est possible, mais vite verbeux. Et si j’ai trois, quatre conditions, la requête devient une bête à rallonge.
SELECT COUNT(*) AS total,
SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active_count
FROM users;
Avec FILTER
(ou ses équivalents), j’exprime clairement chaque condition.
SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'active') AS active_count
FROM users;
C’est plus lisible, chaque agrégat a son propre filtre. En BigQuery, j’utilise COUNTIF
, en Snowflake COUNT_IF
.
Compatibilité : PostgreSQL (FILTER
), BigQuery (COUNTIF
), Snowflake (COUNT_IF
), SQL Server (SUM(CASE ...)
).
9) Vérifier l’existence sans compter tout
Quand je veux juste savoir si un utilisateur a des commandes en attente, compter toutes les lignes est absurde. Ça scanne la table pour rien.
SELECT COUNT(*) > 0
FROM orders
WHERE user_id = 123 AND status = 'pending';
Avec EXISTS
, le moteur s’arrête dès qu’il trouve une correspondance.
SELECT EXISTS (
SELECT 1
FROM orders
WHERE user_id = 123 AND status = 'pending'
);
C’est immédiat et beaucoup plus économique.
Compatibilité : Tous.
10) L’API qui recolle les morceaux côté client
Renvoyer des dizaines de lignes pour que le client reconstitue une liste est une hérésie. Ça surcharge le réseau et gaspille des ressources côté front.
SELECT user_id, order_id
FROM orders;
Avec ARRAY_AGG
ou JSON_AGG
, je livre directement la liste regroupée :
SELECT user_id,
JSON_AGG(order_id ORDER BY order_id) AS order_ids
FROM orders
GROUP BY user_id;
Le front reçoit des structures prêtes à l’emploi, déjà ordonnées et cohérentes.
Compatibilité : PostgreSQL (ARRAY_AGG
, JSON_AGG
), BigQuery (ARRAY_AGG
, STRUCT
), Snowflake (ARRAY_AGG
, OBJECT_AGG
), SQL Server (FOR JSON PATH
), Databricks (collect_list
).
Conclusion
Ces dix techniques ne sont pas des astuces isolées, ce sont des façons d’apprendre à parler au moteur SQL dans son langage : réduire le volume dès que possible, expliciter l’intention (pas de raccourcis fragiles), et utiliser les bonnes fonctions au bon moment. En comprenant le pourquoi derrière chaque ralentissement, j’ai cessé d’écrire du SQL qui rame et commencé à écrire du SQL qui respire.