ORM vs SQL brut : lequel choisir ?
ORM ou SQL brut : comparez sécurité, vitesse et contrôle, voyez pourquoi le N+1 coûte plus que l abstraction et choisissez le bon outil par requête.
Pour la plupart des applications, le choix par défaut le plus judicieux est un ORM ou un constructeur de requêtes pour les 90 % d’opérations CRUD routinières, et du SQL écrit à la main pour le petit sous-ensemble de requêtes de reporting, d’analytique et de chemins critiques qui le justifient réellement — ce n’est pas une décision binaire. La vraie question n’est pas « lequel est meilleur dans l’absolu », mais « lequel pour cette requête, dans cette équipe, avec ces contraintes ». Cet article vous propose une règle de décision, un tableau comparatif, et le seul anti-pattern — la requête N+1 — qui affecte les équipes bien plus souvent que la surcharge d’abstraction ne l’a jamais fait. Les exemples s’appuient sur l’écosystème JS/TS (Prisma, Drizzle, Kysely, Knex), là où la plupart de ces décisions se prennent aujourd’hui.
Points clés
- Adoptez par défaut un ORM ou un constructeur de requêtes pour les opérations CRUD courantes, et recourez au SQL brut uniquement pour les requêtes de reporting, d’analytique et les chemins critiques qui le nécessitent de façon mesurable — la plupart des applications en production finissent par utiliser les deux.
- Le vrai coût de performance d’un ORM n’est presque jamais l’abstraction elle-même ; c’est le pattern de requête N+1, où le chargement d’une liste puis de ses relations ligne par ligne transforme un seul chargement de page en centaines d’allers-retours.
- On corrige le N+1 en chargeant la relation en une seule requête (le
includede Prisma ou unJOIN) et en sélectionnant uniquement les colonnes affichées — pas en abandonnant l’ORM. - Les constructeurs de requêtes comme Drizzle, Kysely et Knex constituent une troisième option souvent négligée : des requêtes à la syntaxe SQL, vérifiées par le typage, avec un contrôle total du SQL généré et sans couche de mapping objet.
- Les ORMs paramètrent les requêtes par défaut, ce qui élimine le vecteur d’injection SQL le plus courant ; le SQL brut n’est aussi sûr que votre rigueur dans l’utilisation des requêtes paramétrées.
Ce qu’est un ORM, et ce que signifie « SQL brut »
Un ORM (Object-Relational Mapper) est une bibliothèque qui fait correspondre les tables de base de données à des objets dans votre langage de programmation, vous permettant d’interroger les données via des appels de méthodes et des modèles typés plutôt qu’en écrivant des chaînes SQL à la main. Il génère le SQL, l’envoie à la base de données et réhydrate les lignes en objets, en gérant les relations, la paramétisation et le mapping de types au passage. Dans l’écosystème JS/TS, Prisma et TypeORM sont les exemples d’ORM complets. Une lecture typique avec Prisma ressemble à ceci :
// Utilisateurs avec leurs articles, en un seul appel
const users = await prisma.user.findMany({
where: { posts: { some: { title: { contains: 'test' } } } },
include: { posts: true },
});
// users[0].posts est déjà un tableau typé — aucune transformation manuelle nécessaire
Le SQL brut consiste à écrire soi-même le texte de la requête et à l’exécuter via un pilote de base de données, puis à remodeler les lignes de résultats en la structure dont votre application a besoin. Vous avez un contrôle total sur la requête exacte, mais vous êtes également responsable du mapping. La même lecture « utilisateurs avec leurs articles » via pg nécessite une jointure et un regroupement manuel :
const { rows } = await pool.query(
`SELECT u.id, u.name, p.id AS post_id, p.title
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
WHERE p.title LIKE $1`,
['%test%'],
);
// Lignes plates -> utilisateurs imbriqués, fait à la main
const users = Object.values(
rows.reduce((acc, r) => {
acc[r.id] ??= { id: r.id, name: r.name, posts: [] };
if (r.post_id) acc[r.id].posts.push({ id: r.post_id, title: r.title });
return acc;
}, {} as Record<number, { id: number; name: string; posts: any[] }>),
);
Ce reduce représente le vrai coût du SQL brut pour les lectures relationnelles : la base de données retourne des lignes, pas des graphes d’objets, et vous réécrivez la logique de jointure et de regroupement à chaque fois. Le paramètre fictif $1 joue également un rôle important — voir la section sécurité ci-dessous.
ORM vs SQL brut : la comparaison qui tranche vraiment
Discover how at OpenReplay.com.
La décision repose sur cinq critères. Voici comment les trois approches — ORM complet, constructeur de requêtes et SQL brut — se comparent sur chacun d’eux :
| Critère | ORM complet (Prisma, TypeORM) | Constructeur de requêtes (Drizzle, Kysely, Knex) | SQL brut |
|---|---|---|---|
| Sécurité contre les injections | Paramétré par défaut | Paramétré par défaut | Sûr uniquement avec des requêtes paramétrées |
| Vitesse de développement (CRUD) | La plus rapide — relations et hydratation gérées | Rapide — syntaxe SQL, vous écrivez les jointures | La plus lente — mapping manuel |
| Migrations / schéma | Intégrées (Prisma Migrate, TypeORM) | Intégrées ou outil associé (Drizzle Kit, migrations Knex) | Scripts DDL écrits à la main |
| Portabilité entre SGBD | Élevée — changement de fournisseur avec peu de modifications | Moyenne — sensible aux dialectes | Faible — SQL spécifique au dialecte |
| Contrôle / fonctionnalités spécifiques au SGBD | Limité ; nécessite une échappatoire | Élevé — proche du SQL | Total — toutes les fonctionnalités, toutes les directives |
| Sécurité des types | De bout en bout depuis le schéma | Forte (Kysely/Drizzle) ; au mieux (Knex) | Aucune sans outillage supplémentaire |
Sur le plan de la sécurité, la réalité pratique est simple : les ORMs et les constructeurs de requêtes paramètrent les valeurs par défaut, ce qui élimine le vecteur d’injection classique. Le SQL brut n’est aussi sûr que votre rigueur — la documentation de Prisma avertit que si vous utilisez la méthode non sécurisée avec des entrées utilisateur, vous ouvrez la possibilité d’attaques par injection SQL, qui peuvent exposer vos données à des modifications ou suppressions. Utilisez des paramètres fictifs ($1, ?) et n’interpolez jamais les entrées utilisateur dans la chaîne de requête, et le SQL brut est sûr ; négligez cette discipline et il ne l’est plus.
Sur les migrations et la portabilité, les ORMs l’emportent dans les cas courants. Une modification de schéma devient une migration versionnée et reproductible qui s’exécute de manière identique dans tous les environnements, et le changement de fournisseur de base de données se résume essentiellement à une modification de configuration. Le SQL brut ne vous offre aucun de ces avantages gratuitement — vous gérez vous-même les scripts DDL et vos requêtes sont liées à la syntaxe d’un seul dialecte. Sur le plan du contrôle, le SQL brut l’emporte sans conteste : les fonctions de fenêtrage, les CTE récursifs, les directives d’index spécifiques à la base de données et l’optimisation via EXPLAIN ANALYZE sont tous accessibles sans se battre contre une abstraction.
La vérité sur les performances : c’est le N+1, pas l’abstraction
La critique la plus répandue contre les ORMs — « ils génèrent des requêtes lentes » — est en grande partie fausse pour les opérations CRUD et en grande partie vraie pour un pattern spécifique. Les ORMs modernes génèrent un SQL compétent pour les lectures et écritures courantes. Le vrai coût de performance est le problème de requête N+1 : vous chargez une liste de N lignes, puis déclenchez une requête supplémentaire par ligne pour récupérer une relation, transformant un seul chargement de page en N+1 allers-retours vers la base de données.
Voici l’anti-pattern, qui se cache souvent dans une boucle d’apparence anodine :
// 1 requête pour les utilisateurs...
const users = await prisma.user.findMany();
// ...puis N de plus, une par utilisateur — c'est le piège N+1
for (const user of users) {
user.posts = await prisma.post.findMany({ where: { authorId: user.id } });
}
La solution n’est pas d’abandonner l’ORM. Vous chargez la relation en une seule requête et sélectionnez uniquement les colonnes que vous affichez réellement :
const users = await prisma.user.findMany({
include: { posts: { select: { id: true, title: true } } },
});
Cela réduit 1 + N requêtes à une seule. Le symptôme côté utilisateur d’un N+1 non corrigé est une page de liste ou un tableau de bord réactif sur des données de test et lent en volume de production — exactement le type de latence côté client qu’une session replay met en évidence, vous renvoyant vers la couche de requêtes pour compter les appels et ajouter le chargement anticipé.
Inspectez le SQL généré avant d’incriminer l’outil
Avant de conclure qu’un ORM est lent, regardez ce qu’il émet réellement. Prisma journalise chaque requête lorsque vous instanciez le client avec log: ['query'] ; Kysely expose .compile() pour retourner le SQL et ses paramètres ; et Drizzle expose une méthode .toSQL() sur une requête dans le même but. Exécutez ensuite EXPLAIN ANALYZE sur le résultat pour voir le plan que la base de données exécute réellement.
const prisma = new PrismaClient({ log: ['query'] });
// chaque requête exécutée par Prisma est maintenant affichée — comptez-les, copiez-les dans EXPLAIN ANALYZE
Cela transforme « l’ORM est lent » d’une intuition en un flux de travail inspectable : lisez le SQL, comptez les allers-retours, vérifiez le plan.
Une note sur le moteur lui-même : l’un des changements les plus significatifs dans Prisma 7 est la suppression complète du moteur de requêtes basé sur Rust au profit d’une implémentation TypeScript. Le nouveau Query Compiler s’exécute en TypeScript et WebAssembly, ce qui élimine l’étape de sérialisation inter-langages et se traduit par une exécution de requêtes plus rapide. De plus, le moteur ne dépendant plus d’un binaire natif, vous pouvez désormais utiliser Prisma dans des environnements prenant en charge JavaScript ou WASM, tels que Cloudflare Workers, Bun et Deno. Prisma indique que la réécriture est souvent significativement plus rapide là où cela compte le plus — sur les requêtes volumineuses et complexes — tout en restant à parité pour les plus simples. Ce que cela signifie pour cette comparaison : le débat sur le moteur a changé de forme sans disparaître, et il reste secondaire par rapport au nombre de requêtes. Pour l’écrasante majorité des pages lentes, le N+1 et la sur-récupération de données en sont la cause, pas la couche d’abstraction.
Les constructeurs de requêtes : la troisième option que le cadrage binaire occulte
La formulation « ORM vs SQL brut » masque un solide terrain intermédiaire. Les constructeurs de requêtes comme Drizzle, Kysely et Knex vous permettent d’écrire des requêtes à la syntaxe SQL, vérifiées par le typage, tout en conservant un contrôle total sur le SQL généré, sans la couche de mapping objet d’un ORM. Kysely est le constructeur de requêtes SQL le plus puissant et le plus sûr en termes de types pour TypeScript, utilisé en production par Deno, Maersk et Cal.com, avec un TypeScript moderne et aucune surcharge à l’exécution. C’est une couche d’abstraction légère au-dessus du SQL, axée sur la familiarité par la nomenclature et la structure, et la prévisibilité par une compilation 1:1.
const users = await db
.selectFrom('users')
.innerJoin('posts', 'posts.author_id', 'users.id')
.select(['users.id', 'users.name', 'posts.title'])
.where('posts.title', 'like', '%test%')
.execute();
La forme reflète le SQL, mais les noms de colonnes et de tables sont vérifiés par rapport à votre schéma. Comme le formule la documentation de Kysely, un constructeur de requêtes vous donne un contrôle total sur votre SQL tout en permettant à TypeScript de détecter les erreurs tôt, avant même l’exécution de votre code. Knex est l’option historique et fonctionne avec Postgres, MySQL, SQLite et d’autres, mais soyez conscient de ses limites en matière de typage : selon son propre guide, la prise en charge de TypeScript est au mieux, tous les patterns d’utilisation ne peuvent pas être vérifiés par le typage, et l’absence d’erreurs de type ne garantit pas actuellement que les requêtes générées seront correctes, il est donc recommandé d’écrire des tests même avec TypeScript. Pour un nouveau projet TS nécessitant une sécurité des types sans la lourdeur d’un ORM, Kysely ou Drizzle est le choix le plus solide.
Une règle de décision par scénario
Adaptez l’outil à la charge de travail plutôt que d’en choisir un pour toute la base de code :
- CRUD simple à modéré, une équipe et un schéma en évolution → ORM. Vous bénéficiez de requêtes paramétrées, de migrations versionnées, du chargement des relations et de types de bout en bout. Cela couvre la majeure partie du code applicatif.
- Reporting, analytique, opérations en masse ou chemin critique mesuré → SQL brut, vues ou procédures stockées. Lorsque vous avez besoin de fonctions de fenêtrage, d’agrégations complexes ou de plans optimisés à la main vérifiés avec
EXPLAIN ANALYZE, écrivez le SQL directement. - Vous souhaitez la sécurité des types et le contrôle du SQL sans ORM → constructeur de requêtes. Drizzle ou Kysely vous offrent des requêtes typées à la syntaxe SQL avec une sortie prévisible et aucun comportement N+1 caché.
Le modèle hybride sur lequel atterrissent la plupart des applications en production
En pratique, les bases de code matures ne choisissent pas un camp — elles utilisent un ORM pour la majeure partie du travail et conservent une échappatoire en SQL brut pour le reste. Prisma rend cela explicite avec TypedSQL : une fonctionnalité de Prisma ORM qui vous permet d’écrire vos requêtes SQL brutes de manière entièrement sûre en termes de types. Vous écrivez le SQL dans un fichier .sql et l’appelez via une fonction générée et typée :
import { getUsersByAge } from './generated/prisma/sql';
const users = await prisma.$queryRawTyped(getUsersByAge(18, 30));
C’est une fonctionnalité en préversion : vous l’activez avec previewFeatures = ["typedSql"] dans le bloc générateur, et parce qu’elle est typée statiquement, elle peut ne pas gérer certains scénarios tels que les clauses WHERE générées dynamiquement — ceux-ci nécessitent toujours les méthodes brutes non typées. Prisma formule le flux de travail exactement comme cet article : une abstraction de haut niveau qui sert la majorité des requêtes, plus une échappatoire sûre en termes de types pour lorsque vous devez rédiger le SQL directement. C’est pourquoi la réponse moderne à « ORM ou SQL brut » est généralement « un ORM (ou constructeur de requêtes) qui vous permet de basculer vers le SQL brut quand vous en avez besoin. »
Conclusion
Adoptez l’ORM ou le constructeur de requêtes comme choix par défaut pour les opérations CRUD courantes, recourez au SQL brut pour les requêtes qui le justifient de façon mesurable, et traitez les deux comme des couches d’une seule stratégie d’accès aux données plutôt que comme des camps rivaux. La prochaine étape concrète sur tout projet existant : activez la journalisation des requêtes, identifiez les endpoints de liste ou de tableau de bord, comptez les requêtes par requête HTTP, et corrigez les N+1 avec le chargement anticipé et une sélection de colonnes plus stricte — cette seule passe récupère généralement plus de performances que n’importe quelle réécriture ORM-versus-SQL ne l’aurait jamais fait.
FAQ
Quelle est la différence entre un ORM et un constructeur de requêtes ?
Un ORM fait correspondre les tables de base de données à des objets et réhydrate les résultats de requêtes en modèles typés, gérant les relations et le mapping objet pour vous, tandis qu'un constructeur de requêtes génère du SQL vérifié par le typage mais retourne des lignes brutes sans couche de mapping objet. Prisma et TypeORM sont des ORMs ; Drizzle, Kysely et Knex sont des constructeurs de requêtes. Les constructeurs de requêtes sont plus proches du SQL, offrant plus de contrôle sur la requête générée avec moins d'abstraction et aucun comportement de chargement de relation caché.
L'utilisation d'un ORM prévient-elle les injections SQL ?
Oui, pour une utilisation normale. Les ORMs et les constructeurs de requêtes paramètrent les valeurs par défaut, envoyant les entrées utilisateur comme paramètres liés plutôt que de les interpoler dans la chaîne de requête, ce qui élimine le vecteur d'injection classique. L'exception concerne les méthodes de requêtes brutes non sécurisées : la documentation de Prisma avertit que l'utilisation de la méthode non sécurisée avec des entrées utilisateur ouvre la possibilité d'injections SQL. Le SQL brut n'est aussi sûr que votre rigueur dans l'utilisation des paramètres fictifs comme dollar-un ou point d'interrogation.
Le SQL brut est-il plus rapide qu'un ORM ?
Pas dans la plupart des cas réels. Les ORMs modernes génèrent un SQL compétent pour les opérations CRUD courantes, et l'écart de performance habituel vient du nombre de requêtes, pas de l'abstraction. La cause dominante des pages lentes est le pattern N+1, où le chargement d'une liste puis de ses relations ligne par ligne crée des centaines d'allers-retours. Le SQL brut vaut la peine d'être utilisé pour les requêtes de reporting, d'analytique et les chemins critiques mesurés qui nécessitent réellement des plans optimisés à la main.
Comment voir le SQL réel généré par un ORM ?
Chaque outil expose directement le SQL généré. Prisma journalise chaque requête lorsque vous instanciez le client avec l'option de journalisation des requêtes activée. Kysely expose une méthode compile qui retourne la chaîne SQL et ses paramètres. Drizzle expose une méthode toSQL sur une requête dans le même but. Une fois que vous avez le SQL, exécutez EXPLAIN ANALYZE dans PostgreSQL pour inspecter le plan d'exécution et confirmer si les index sont utilisés comme prévu.
Puis-je écrire du SQL brut dans un projet utilisant un ORM ?
Oui, et c'est le pattern courant en production. La plupart des ORMs conservent une échappatoire en SQL brut aux côtés de leurs requêtes générées. Prisma propose TypedSQL, une fonctionnalité en préversion activée avec le flag de préversion typedSql, vous permettant d'écrire du SQL dans un fichier dot-sql et de l'appeler via une fonction générée et entièrement typée. Étant donné qu'elle est typée statiquement, elle ne gère pas les clauses WHERE générées dynamiquement, qui nécessitent toujours les méthodes de requêtes brutes non typées.