12k
All articles

Quand l'ORM n'est pas le bon outil

Quand un ORM devient un goulot, passez au SQL brut pour les requêtes N+1, fonctions fenêtrées, CTE, écritures en masse et paramètres sûrs.

OpenReplay Team
OpenReplay Team
Quand l'ORM n'est pas le bon outil

Un ORM est le choix par défaut pertinent pour le CRUD, et devient le mauvais outil dès l’instant où votre requête cesse de ressembler à un accès objet pour prendre l’allure d’un rapport.

Vous connaissez sans doute le moment de bascule : un endpoint de liste qui semblait convenable en staging met quatre secondes en production, et le journal des requêtes déborde de SELECT quasi identiques que personne n’a écrits à la main. Les fonctions de fenêtrage, les CTE, les agrégations multi-jointures et les opérateurs propres à un SGBD sont précisément les cas où le SQL généré par un ORM devient inefficace ou impossible à produire, et où descendre au SQL brut se justifie pleinement. Cet article trace la ligne avec précision : là où le mapping objet-relationnel est le bon choix par défaut, là où il devient sournoisement le goulot d’étranglement, et comment le contourner sans renoncer à la protection contre les injections.

À retenir

  • Les ORM sont le bon choix par défaut pour les ~80 % de CRUD simple : ils réduisent le code répétitif, paramètrent automatiquement les entrées et restent agnostiques vis-à-vis de la base de données.
  • Le SQL brut n’est pas intrinsèquement plus rapide qu’un ORM. Il l’emporte spécifiquement lorsque la requête générée par l’ORM constitue le goulot d’étranglement : chemins critiques, opérations en masse ou schémas N+1.
  • Le problème N+1 est la manière la plus courante dont un ORM devient silencieusement le mauvais outil ; corrigez-le d’abord par du chargement anticipé (eager loading), et ne recourez au SQL brut que lorsque même la forme obtenue par chargement anticipé reste inadaptée.
  • Quitter l’ORM ne signifie pas le supprimer. Descendez au SQL brut via sa propre porte de sortie : connection.cursor() ou Manager.raw() pour Django, text() pour SQLAlchemy, TypedSQL pour Prisma.
  • Dès lors que vous écrivez du SQL brut, la protection contre les injections vous incombe : faites toujours passer les entrées utilisateur par des paramètres substituables (%s avec psycopg, $1 avec Postgres/SQLx) et ne les concaténez jamais dans la chaîne de requête.

SQL brut, query builders, ORM : un continuum d’abstraction

« ORM contre SQL brut » n’a jamais été une opposition binaire. L’accès aux données forme un continuum allant du contrôle total au confort total, avec un niveau intermédiaire que la plupart des comparaisons omettent. À une extrémité, le SQL brut vous donne le langage natif de la base de données, sans couche de traduction. À l’autre, les ORM comme celui de Django, ActiveRecord, Hibernate, Prisma, Sequelize ou SQLAlchemy mappent les lignes vers des objets et génèrent le SQL à votre place. Entre les deux se trouvent les query builders.

Un query builder formalise les motifs de requêtes sous forme de méthodes chaînables tout en restant proche du SQL qu’il émet. La plupart des ORM exposent aussi un moyen de transmettre une chaîne brute à la base de données, ce qui court-circuite l’échappement assuré par leurs méthodes de requête habituelles et rouvre la porte à l’injection SQL. Un builder est un outil différent : il compose du SQL par programmation sans prétendre offrir un accès objet. Knex est un query builder JavaScript activement maintenu, sur la branche 3.3.0 depuis juin 2026 d’après son changelog ; côté JVM, jOOQ est un DSL SQL typé, actuellement sur la branche 3.21, dont l’Open Source Edition cible le JDK 21. Ni l’un ni l’autre n’est un ORM, et tous deux préservent le paramétrage, ce qui est bien l’essentiel. Lorsque l’abstraction de l’ORM vous résiste, le niveau builder constitue souvent la bonne étape intermédiaire avant le SQL écrit à la main.

Quand un ORM est-il le mauvais outil ?

Le signal du changement ne relève pas de l’intuition : il est précis. Passez outre l’ORM lorsque vous rencontrez l’un de ces cinq schémas :

  1. Requêtes analytiques et de type reporting. Fonctions de fenêtrage, CTE récursives, agrégations GROUP BY ... HAVING et rapports multi-jointures : c’est là que le SQL généré devient inefficace ou impossible à exprimer. Un ORM est optimisé pour l’accès objet, pas pour des sorties de type OLAP.
  2. Chemins critiques et opérations en masse. Sur un endpoint à fort trafic ou lors d’un UPDATE/INSERT par lots, les appels à save() ligne par ligne et les allers-retours supplémentaires s’accumulent. Une unique instruction ensembliste remplace des centaines d’écritures ORM.
  3. Le piège des requêtes N+1. Traité en détail plus bas : la défaillance de performance ORM la plus répandue.
  4. Fonctionnalités spécifiques au SGBD. Les opérateurs JSONB de Postgres comme @> et ->>, la recherche plein texte avec tsvector/tsquery, les jointures LATERAL et les fonctions géospatiales PostGIS sont autant de fonctionnalités que beaucoup d’ORM ne savent pas exprimer pleinement ou idiomatiquement. Certains ORM proposent des helpers (le contrib.postgres de Django), mais la couverture reste partielle.
  5. Comportement opaque, « magique ». Lorsque vous ne pouvez ni voir ni ajuster le SQL émis par l’ORM, le débogage et le travail de performance relèvent de la devinette. C’est le décalage d’impédance objet-relationnel qui se traduit en coût réel, avec une dimension sécurité : les méthodes de requête brute que proposent la plupart des ORM échappent à leur propre mécanisme d’échappement, si bien qu’interpoler une valeur dans l’une d’elles vous expose.

Le problème des requêtes N+1, nommé et corrigé

Le problème N+1 est la manière la plus courante dont un ORM devient silencieusement le mauvais outil : le chargement paresseux déclenche une requête par ligne, si bien qu’une liste de 100 éléments se transforme discrètement en 101 allers-retours. La boucle a l’air anodine :

# One query for authors, then one MORE per author for their books
for author in Author.objects.all():
    print(author.name, author.books.count())

La solution, c’est le chargement anticipé, pas le SQL brut. Les méthodes select_related et prefetch_related de Django condensent ces allers-retours en un JOIN ou une unique requête IN :

# Two queries total, regardless of author count
authors = Author.objects.prefetch_related("books")

Corrigez d’abord le N+1 par du chargement anticipé, et ne recourez au SQL brut que lorsque même la forme obtenue par chargement anticipé reste inadaptée — par exemple lorsqu’il vous faut un agrégat fenêtré par auteur que l’ORM traduirait par un aller-retour supplémentaire. Les requêtes ORM inefficaces s’annoncent rarement dans votre code ; elles se manifestent par des réponses d’API et des chargements de page lents. Un outil de session replay comme OpenReplay met en évidence la requête réseau lente dans la timeline de session, ce qui vous oriente vers l’endpoint dont la requête backend mérite attention : la localisation du symptôme, non la requête elle-même. Pour approfondir cet arbitrage, consultez le guide d’OpenReplay sur la prévention des injections SQL.

À quoi renoncez-vous en écrivant du SQL brut ?

Dès lors que vous écrivez du SQL brut, vous héritez de la tâche que l’ORM assurait silencieusement pour vous : la protection contre les injections. Faites toujours passer les entrées utilisateur par des paramètres substituables et ne les concaténez jamais dans la chaîne de requête. Le guide de Django sur l’exécution de requêtes SQL brutes en expose le mécanisme : cursor.execute() accepte des paramètres %s accompagnés d’une liste de valeurs distincte, et le driver échappe chaque valeur à l’entrée, de sorte qu’elle ne fait jamais partie du texte de l’instruction.

# Safe: %s is the psycopg/DB-API placeholder, not string formatting
from django.db import connection
with connection.cursor() as cursor:
    cursor.execute("SELECT * FROM book WHERE author = %s", [user_input])
    rows = cursor.fetchall()

Laissez les paramètres nus : mettre %s entre guillemets dans la chaîne SQL annule cette protection. SQLx en Rust tire son marqueur de paramètre de la base de données : $1 sous Postgres, mais ? sous MySQL, MariaDB et SQLite. Au-delà des injections, vous assumez également davantage de code répétitif, un couplage plus fort à un dialecte SQL donné et le mapping manuel des lignes de résultat vers vos objets.

Renoncer à l’ORM n’oblige pas à renoncer au filet de sécurité. Les outils à vérification à la compilation le conservent : SQLx (0.9) valide les requêtes contre le schéma avant l’exécution de l’application, et sa propre documentation précise qu’il ne s’agit pas d’un ORM ; jOOQ (3.21) fait de même sur la JVM. Les query builders se situent entre les deux. « ORM contre SQL brut » est une fausse alternative : le véritable axe est le niveau d’abstraction que mérite chaque requête prise individuellement.

Le verdict pragmatique : ORM ou SQL brut

Utilisez l’ORM pour les ~80 % de CRUD simple et descendez au SQL brut via sa propre porte de sortie pour les requêtes spécifiques qui le justifient. Quitter l’ORM ne signifie pas le supprimer. Django documente trois voies : RawSQL pour insérer un fragment paramétré dans une requête ORM, Manager.raw() pour une requête brute qui renvoie malgré tout des instances de modèles, et connection.cursor() pour contourner entièrement la couche modèle. SQLAlchemy expose text() ; Prisma propose TypedSQL, actuellement en preview, ainsi que $queryRaw pour un accès non typé.

La décision se résume à un court tableau :

SituationOutil à privilégier
CRUD, formulaires, relations standardORM
La portabilité entre dialectes compteORM ou query builder
Rapports multi-jointures, fonctions de fenêtrage, CTESQL brut
Endpoint critique ou écriture en masseSQL brut
N+1 dans une vue de listeChargement anticipé d’abord, SQL brut si nécessaire
Fonctionnalité propriétaire que l’ORM ne sait pas nommerSQL brut

Le SQL brut n’est pas une réécriture ; c’est une porte de sortie ciblée pour la poignée de requêtes où le SQL généré constitue le goulot d’étranglement. Conservez l’ORM comme choix par défaut, profilez l’endpoint lent, et substituez-y du SQL paramétré écrit à la main exactement là où le plan d’exécution démontre que cela se justifie, et nulle part ailleurs.

FAQ

Le SQL brut est-il réellement plus rapide qu'un ORM ?

Pas intrinsèquement. Une requête ORM bien écrite et une requête brute bien écrite passent par le même planificateur de requêtes : le SQL brut n'est donc pas automatiquement plus rapide. Il l'emporte spécifiquement lorsque la requête générée par l'ORM constitue le goulot d'étranglement — allers-retours supplémentaires, schémas N+1, sélections larges et non bornées, ou chemins critiques où des instructions ensemblistes remplacent des écritures ligne par ligne. Le gain de vitesse vient de la correction d'un SQL généré défaillant, non du SQL brut en soi.

Quelle est la différence entre un query builder et un ORM ?

Un query builder compose du SQL par programmation via des méthodes chaînables tout en restant proche du SQL qu'il émet ; il ne mappe pas les lignes vers des objets. Un ORM mappe les lignes de la base vers des objets du langage et masque totalement le SQL. Knex est un query builder pour JavaScript et jOOQ un DSL SQL typé pour la JVM — aucun des deux n'est un ORM. Tous deux préservent le paramétrage : vous abandonnez l'abstraction de mapping objet sans perdre la protection contre les injections.

Comment écrire du SQL brut sans exposer mon application à l'injection SQL ?

Faites passer chaque valeur fournie par l'utilisateur par des paramètres substituables et ne concaténez jamais d'entrée dans la chaîne de requête. Avec Django et psycopg, le marqueur est %s, et le driver de base de données échappe automatiquement les paramètres ; SQLx en Rust tire son marqueur de la base de données, soit $1 sous PostgreSQL mais ? sous MySQL, MariaDB et SQLite. N'ajoutez pas de guillemets autour des marqueurs dans la chaîne SQL. Les outils à vérification à la compilation comme SQLx valident les requêtes contre le schéma avant l'exécution de l'application, ce qui ajoute une couche de sécurité supplémentaire.

Puis-je utiliser du SQL brut au sein d'un ORM sans supprimer l'ORM ?

Oui. Tous les ORM majeurs proposent une porte de sortie qui permet d'exécuter du SQL brut tout en conservant l'ORM comme choix par défaut. Django offre connection.cursor() pour l'exécution directe, Manager.raw() pour renvoyer des instances de modèles, et RawSQL pour des fragments paramétrés au sein de requêtes ORM ; SQLAlchemy expose text() ; Prisma propose TypedSQL en preview ainsi que $queryRaw pour un accès non typé. Utilisez l'ORM pour le CRUD standard et ne le contournez vers le SQL brut que pour les requêtes spécifiques où le SQL généré constitue le goulot d'étranglement.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.