Cinq motifs SQL qui remplacent une boucle Python — et pourquoi ils gagnent
Le Python le plus cher que je relis, c’est for order in orders après un SELECT *. Dernière ligne par groupe, mois sur mois, comptes filtrés et upserts sont des requêtes. Comment je les écris dans Postgres au lieu de télécharger la table.
Le Python le plus cher que je relis n’est pas le handler FastAPI sans types. C’est le for order in orders qui existe parce que personne n’a écrit une fonction de fenêtre. La boucle a l’air honnête. C’est aussi comme ça qu’un rapport de quatre secondes devient quarante, puis un timeout le lundi après un bon week-end de ventes.
J’ai déjà écrit comment je choisis un type de base. Ici la couche suivante : le SQL que j’écris vraiment quand la question produit est « dernière commande par client », « revenu ce mois contre le précédent », ou « insérer cette réservation si le créneau est libre ». Postgres répond en un aller-retour. Python aussi — après avoir téléchargé la table. L’article jumeau : comment cette requête doit quitter Python sans ramener le tas.
1. La boucle qui télécharge la table
Un script typique : SELECT * FROM orders, puis grouper dans un dict, puis prendre le created_at le plus récent par customer_id. Sur 800 lignes, ça passe. Sur 800 000, c’est un ticket support. La base a déjà l’index, le tri et la mémoire pour ça. Vous avez payé pour ça. Python comme second moteur de requêtes, c’est comme ça que le coût sort en latence, pas sur une facture prévue.
- Si le résultat est plus petit que la source et que la règle a un nom — last, sum, rank, exists — c’est une requête.
- Si la règle est un essai métier — remboursements avec exception humaine, un paragraphe de politique — elle peut rester en Python.
2. Dernière ligne par groupe — DISTINCT ON et ROW_NUMBER
Abonnement courant par espace de travail. Dernier statut de facture par commande. Le chauffeur qui a pingé en dernier. C’est la première boucle que je supprime. Dans Postgres j’écris DISTINCT ON (workspace_id) et ORDER BY workspace_id, created_at DESC. Le cousin portable : ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at DESC), puis garder rn = 1.
DISTINCT ON est plus court, et je l’utilise quand Postgres est à moi. ROW_NUMBER, je l’écris quand la même requête doit survivre à un déménagement, ou quand j’ai besoin du rang 1 et du rang 2 — le plan précédent, le dauphin. Ne chargez pas tous les abonnements pour réduire en Python. Vous vous tromperez sur les égalités, et vous chargerez des colonnes que vous n’affichez jamais.
3. Totaux cumulés et « depuis le mois dernier » — SUM OVER et LAG
Le mois sur mois n’est pas une jointure de la table sur elle-même dans une boucle. LAG(revenue) OVER (PARTITION BY workspace_id ORDER BY month) pose le mois dernier sur la même ligne. SUM(amount) OVER (PARTITION BY customer_id ORDER BY created_at) est un solde cumulé. Les fonctions de fenêtre gardent le grain de la ligne et voient quand même le voisinage. GROUP BY écrase. Une boucle Python reconstruit ce voisinage dans un dict et oublie les fuseaux.
S’il vous faut la ligne de détail et le total du groupe, SUM(amount) OVER (PARTITION BY order_id) bat une deuxième requête et bat le N+1 de l’ORM. Toute l’astuce : garder le grain que vous affichez, calculer le voisinage dans le moteur.
4. Comptes conditionnels — FILTER au lieu de if
COUNT(*) FILTER (WHERE status = paid) à côté de COUNT(*) FILTER (WHERE status = refunded), c’est un seul parcours. La version Python, c’est une passe et deux compteurs — jusqu’à ce qu’on ajoute un troisième statut dans une deuxième boucle, ou que l’export grossisse et que vous parcouriez deux fois. FILTER se lit. Un CASE dans SUM marche sur les moteurs sans FILTER. Dans Postgres je préfère FILTER : l’intention est sur la page, ce compte a un prédicat.
5. Upsert au lieu de lire puis insérer
La course : lire « cet e-mail est-il libre », puis insérer. Deux onglets, deux workers, une contrainte unique que vous découvrez en prod. INSERT ... ON CONFLICT (email) DO UPDATE — ou DO NOTHING — est la transaction que la base sait déjà faire. Même famille : UPDATE ... WHERE version = $version pour un verrou optimiste. Check-then-write en Python, c’est une moins bonne base écrite à la main.
- Créneaux de réservation, clés d’idempotence, « créer le client s’il manque » — ce sont des upserts, pas des if.
- Un index unique fait partie du produit. Sans lui, ON CONFLICT est du théâtre.
6. Comment je vérifie que je n’ai pas inventé une requête plus lente
EXPLAIN (ANALYZE, BUFFERS) sur le chemin lent. Si vous voyez un sequential scan sur une table devenue grande, il vous fallait un index aligné sur la partition et l’ordre — (workspace_id, created_at DESC) pour DISTINCT ON. Une fonction de fenêtre n’est pas gratuite. Elle reste moins chère que d’envoyer le tas à l’app. Si le plan trie deux millions de lignes pour un tableau de bord qui en montre vingt, ajoutez une borne de date. Un SQL qui remplace une boucle peut encore être une mauvaise boucle dans le moteur.
Conclusion : nommez le résultat, écrivez la requête
Python est un bon endroit pour HTTP, la politique, et la phrase que voit l’utilisateur. Un mauvais endroit pour réinventer GROUP BY. Cinq formes — last-per-group, lag, somme cumulée, agrégats filtrés, upsert — et la plupart des scripts « malins » que je relis deviennent une vue ou une seule requête dans une fonction de dépôt. L’autre article, c’est le Python qui refuse d’annuler ce travail.
On discute de votre projet ?
Je suis ingénieure web senior, spécialisée en React et Next.js - disponible en freelance partout dans le monde.
Localisation
Kyiv, Ukraine
Upwork
Voir le profilTelegram
Me contacterViber
Me contacter