Qu’est-ce que Prisma ?
Prisma est un ORM schema-first pour TypeScript et Node.js. Vous décrivez vos données une seule fois dans schema.prisma, vous lancez un générateur, et vous obtenez un client dont les méthodes et les types de retour sont directement dérivés de ce schéma. Il n’y a pas de décorateurs, pas de classes de repository et pas de chaînes SQL écrites à la main pour les cas d’utilisation courants.
L’idée est que le schéma de la base de données devient un fichier déclaratif unique, lisible par les humains et exploitable par les outils. À partir de celui-ci, Prisma produit un client typé que vous importez dans votre code, des migrations SQL que vous pouvez réviser et commiter, ainsi qu’une interface Studio pour explorer vos données. Prisma supporte PostgreSQL, MySQL, MariaDB, SQLite, SQL Server, CockroachDB et MongoDB ; le client généré traduit chaque appel en SQL ou en protocole MongoDB avant de mapper les lignes retournées en objets simples.
Si vous avez déjà utilisé un ORM de style ActiveRecord, le changement de paradigme est que Prisma est schema-first et génère un client, plutôt que d’être class-first et basé sur la réflexion au runtime. Cette décision unique explique la plupart de ses points forts ainsi que la plupart de ses contraintes.
Le schéma est la source de vérité
Tout commence dans schema.prisma. Il contient trois types de blocs : un datasource (où se trouve la base de données), un generator (ce qu’il faut générer), et un bloc model par table.
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(uuid())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
}
Quelques conventions sont importantes à assimiler. Un champ se terminant par ? est nullable, tandis qu’un type de liste tel que Post[] représente une relation plutôt qu’une colonne. Les attributs commencent par @ pour les champs et @@ pour les blocs. @id marque la clé primaire, @unique crée un index unique, @default(...) fournit une valeur, et @map ainsi que @@map permettent de renommer la colonne ou la table sous-jacente lorsqu’elle ne correspond pas à votre modèle.
Le modèle ci-dessus correspond à une table User avec les colonnes id, email, name et createdAt, ainsi qu’une relation virtuelle posts que Prisma résout via une jointure ou une seconde requête.
Générer et utiliser le client
Le client Prisma est du code généré. Après avoir modifié le schéma, exécutez :
pnpm prisma generate
Cette commande lit schema.prisma et écrit un client dans node_modules/.prisma/client, ou dans un dossier de votre choix avec le nouveau générateur prisma-client. Vous pouvez ensuite l’instancier une seule fois et le réutiliser :
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const user = await prisma.user.findUnique({
where: { email: "[email protected]" },
});
Comme le client est généré, prisma.user et la structure de user sont tous deux connus du vérificateur de types. Renommez un champ dans le schéma, régénérez le client, et chaque utilisation obsolète échouera à la compilation. Cette boucle de rétroaction est la raison principale pour laquelle les équipes adoptent Prisma.
Dans les environnements serverless ou avec rechargement à chaud (hot-reloading), évitez de créer un nouveau client par requête ou par rechargement. Attachez-en plutôt un à l’objet global :
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
Lecture des données
Prisma expose une méthode par type de lecture. findUnique récupère une seule ligne via un champ unique, findFirst récupère une ligne correspondant à un filtre arbitraire, et findMany retourne une liste.
const post = await prisma.post.findUnique({ where: { id: 42 } });
const latest = await prisma.post.findFirst({
where: { published: true },
orderBy: { createdAt: "desc" },
});
const posts = await prisma.post.findMany({
where: {
published: true,
title: { contains: "prisma", mode: "insensitive" },
authorId: { in: [userId] },
},
orderBy: { createdAt: "desc" },
take: 20,
skip: 0,
});
Les opérateurs de filtrage sont nommés plutôt que symboliques : equals, not, in, notIn, lt, lte, gt, gte, contains, startsWith, endsWith et mode. Combinez-les avec les tableaux AND, OR et NOT :
const posts = await prisma.post.findMany({
where: {
OR: [
{ title: { contains: "orm" } },
{ author: { email: { endsWith: "@example.com" } } },
],
NOT: { published: false },
},
});
take et skip implémentent la pagination par décalage (offset pagination). Pour les ensembles de résultats volumineux, privilégiez la pagination par curseur (cursor pagination), qui pagine à partir de la dernière ligne au lieu de compter depuis le début :
const page = await prisma.post.findMany({
take: 20,
skip: 1,
cursor: { id: lastSeenId },
orderBy: { id: "asc" },
});
findUnique n’acceptera pas un where non unique ; utilisez findFirst dans ce cas. Les variantes findUniqueOrThrow et findFirstOrThrow rejettent la requête avec une erreur au lieu de retourner null, ce qui permet de supprimer une condition lorsque la ligne doit impérativement exister.
Écriture de données
Les opérations de création, de mise à jour et de suppression sont typées de la même manière. create, update, upsert et delete opèrent sur une seule ligne, tandis que createMany, updateMany et deleteMany opèrent sur des ensembles.
const user = await prisma.user.create({
data: { email: "[email protected]", name: "Ada" },
});
await prisma.user.update({
where: { id: user.id },
data: { name: "Ada Lovelace" },
});
await prisma.user.upsert({
where: { email: "[email protected]" },
update: { name: "Ada Lovelace" },
create: { email: "[email protected]", name: "Ada" },
});
await prisma.user.delete({ where: { id: user.id } });
Pour les insertions en masse, createMany émet une seule requête INSERT et est considérablement plus rapide qu’une boucle utilisant create :
await prisma.post.createMany({
data: [
{ title: "Hello", authorId: user.id },
{ title: "World", authorId: user.id },
],
skipDuplicates: true,
});
Le compromis est que createMany ne peut pas écrire de relations imbriquées ; il est réservé aux lignes plates. updateMany et deleteMany acceptent les mêmes filtres que findMany, donc l’absence de where affecte réellement chaque ligne.
Relations, include et select
Les relations sont déclarées des deux côtés. User.posts est une liste et Post.author est une valeur unique, liées ensemble par @relation(fields: [authorId], references: [id]) du côté propriétaire.
Par défaut, Prisma ne retourne que les colonnes scalaires. Pour charger une relation, vous ajoutez include :
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
posts: {
where: { published: true },
orderBy: { createdAt: "desc" },
take: 10,
},
},
});
select est l’outil le plus précis : il permet de choisir exactement quels champs retourner, aussi bien pour le modèle que pour les relations imbriquées.
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
posts: {
select: { title: true },
where: { published: true },
},
_count: { select: { posts: true } },
},
});
Vous ne pouvez pas combiner select et include au même niveau, car select répond déjà à la question de ce qu’il faut retourner. Utilisez select lorsqu’un endpoint a une structure de réponse fixe, et include lorsque vous voulez réellement l’intégralité de l’enregistrement lié. Retourner des lignes entières pour ensuite les filtrer en JavaScript gaspille de la bande passante et de la mémoire ; select permet donc d’éviter une régression courante.
Écritures imbriquées
L’une des fonctionnalités les plus agréables de Prisma est la possibilité d’écrire un parent et ses enfants en un seul appel. Les opérations imbriquées create, connect, update et delete s’exécutent toutes à l’intérieur d’une transaction implicite.
const user = await prisma.user.create({
data: {
email: "[email protected]",
posts: {
create: [
{ title: "First post" },
{ title: "Second post", published: true },
],
},
},
include: { posts: true },
});
Pour connecter des lignes existantes au lieu de les créer, on utilise connect, et pour déconnecter une relation, on utilise disconnect :
await prisma.post.update({
where: { id: postId },
data: {
author: { connect: { email: "[email protected]" } },
},
});
Les écritures imbriquées maintiennent la cohérence des données liées sans que vous ayez à ouvrir manuellement une transaction, ce qui est précisément le type de gestion qu’un ORM doit prendre en charge.
Migrations en développement et en production
Prisma Migrate transforme la différence entre votre schéma et votre base de données en fichiers SQL versionnés sous prisma/migrations.
pnpm prisma migrate dev --name add_posts
pnpm prisma migrate deploy
pnpm prisma migrate status
migrate dev est une commande de développement. Elle compare le schéma à la base de données, écrit une nouvelle migration, l’applique et régénère le client. Elle utilise également une shadow database pour détecter tout décalage (drift), le rôle de développement doit donc avoir l’autorisation de créer et de supprimer des bases de données. Si un décalage est détecté, elle peut proposer de réinitialiser la base de données, ce qui supprime les données.
migrate deploy est la commande de production. Elle applique les migrations en attente et rien d’autre : pas de génération, pas de shadow database, pas de réinitialisation. Exécutez-la dans votre pipeline de déploiement avant que le nouveau code ne commence à recevoir du trafic.
Pour les prototypes jetables, prisma db push ignore complètement l’historique des migrations et force la base de données à correspondre au schéma :
pnpm prisma db push
db push est rapide et pratique, mais ne laisse aucune trace d’audit et peut supprimer des colonnes ou des tables pour adapter le schéma. Ne l’utilisez jamais sur un environnement de production. D’autres commandes utiles sont prisma migrate reset pour supprimer, recréer et re-peupler (seed) une base de données de développement, et prisma migrate diff pour afficher les changements qui seraient appliqués sans les exécuter.
Seeding
Les données de seed doivent se trouver dans prisma/seed.ts. Enregistrez-les dans package.json pour que Prisma sache comment les exécuter :
{
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
await prisma.user.upsert({
where: { email: "[email protected]" },
update: {},
create: {
email: "[email protected]",
name: "Ada",
posts: { create: [{ title: "Welcome" }] },
},
});
}
main()
.then(() => prisma.$disconnect())
.catch(async (error) => {
console.error(error);
await prisma.$disconnect();
process.exit(1);
});
Exécutez-les avec pnpm prisma db seed. Comme le seeding s’exécute après migrate dev et migrate reset, une base de données fraîchement créée n’est jamais vide, ce qui rend l’onboarding et les tests prévisibles.
Transactions
Prisma propose deux API de transaction. La forme séquentielle prend un tableau d’opérations et les exécute dans l’ordre :
const [debit, credit] = await prisma.$transaction([
prisma.account.update({
where: { id: 1 },
data: { balance: { decrement: 5000 } },
}),
prisma.account.update({
where: { id: 2 },
data: { balance: { increment: 5000 } },
}),
]);
La forme interactive vous fournit un client de transaction et vous permet de bifurquer selon les résultats :
await prisma.$transaction(async (tx) => {
const sender = await tx.account.findUniqueOrThrow({ where: { id: 1 } });
if (sender.balance < 5000) throw new Error("insufficient_funds");
await tx.account.update({
where: { id: 1 },
data: { balance: { decrement: 5000 } },
});
await tx.account.update({
where: { id: 2 },
data: { balance: { increment: 5000 } },
});
});
Utilisez tx pour chaque requête à l’intérieur d’une transaction interactive ; l’utilisation du client prisma externe permet de sortir de la transaction. Vous pouvez ajuster le comportement avec maxWait, qui contrôle le temps d’attente d’une connexion, et timeout, qui limite la durée d’exécution de la transaction, et définir le niveau d’isolation avec isolationLevel. Gardez vos transactions courtes et ne laissez jamais une transaction ouverte pendant un appel HTTP ou une interaction utilisateur.
Le SQL brut quand c’est nécessaire
Le query builder couvre la plupart des lectures, mais les requêtes de reporting et les fonctionnalités spécifiques aux bases de données nécessitent parfois du SQL. $queryRaw est un template taggé qui paramètre les valeurs pour vous :
import { Prisma } from "@prisma/client";
const rows = await prisma.$queryRaw<
{ day: Date; revenue: bigint }[]
>(Prisma.sql`
SELECT date_trunc('day', created_at) AS day,
sum(total_cents) AS revenue
FROM orders
WHERE status = 'paid'
GROUP BY 1
ORDER BY 1 DESC
`);
Utilisez $queryRaw pour SELECT et $executeRaw pour les écritures. Tous deux acceptent des templates taggés, ce qui prévient les injections SQL car les valeurs interpolées deviennent des paramètres liés. $queryRawUnsafe et $executeRawUnsafe existent pour le SQL véritablement dynamique et doivent être considérés comme dangereux : n’interpolez jamais d’entrées utilisateur à l’intérieur. Prisma.sql et Prisma.join vous permettent de composer des fragments tout en conservant l’intégrité du paramétrage.
Pooling, serverless et Accelerate
Chaque instance de PrismaClient possède son propre pool de connexions. Sur un serveur traditionnel (long-lived), c’est exactement ce que l’on recherche. En revanche, sur une plateforme serverless, chaque instance de fonction crée son propre client et donc son propre pool ; un pic de trafic peut ainsi épuiser les max_connections de la base de données en quelques secondes.
Le premier levier est la chaîne de connexion. Limitez la taille du pool et le temps d’attente :
DATABASE_URL="postgresql://user:pass@host:5432/shop?connection_limit=5&pool_timeout=10"
Dans les fonctions où chaque instance ne traite qu’une seule requête à la fois, connection_limit=1 est souvent le choix approprié. Derrière PgBouncer, ajoutez ?pgbouncer=true pour que Prisma cesse d’utiliser des prepared statements, car le transaction pooling ne peut pas les préserver. Si vous ne pouvez pas modifier les limites de connexion de votre base de données, Prisma Accelerate s’interpose comme un pooler et un cache managés ; vous enveloppez le client avec son extension et routez vos requêtes via un endpoint global. L’ancien Data Proxy résolvait le même problème et a été remplacé par Accelerate.
Quel que soit votre choix, la règle reste la même : réutilisez un seul client par processus et veillez à ce que le nombre d’instances de votre application ne génère pas plus de connexions que ce que la base de données peut supporter.
La question du N+1
Un problème N+1 survient lorsque vous récupérez une liste, puis effectuez une requête par ligne pour charger une relation. Prisma évite la forme classique car include et select chargent les relations dans le cadre du même appel : pour un findMany avec une seule relation, il exécute une requête pour les parents et une pour les enfants, et non une requête par parent.
Vous pouvez réintroduire ce problème vous-même en utilisant une boucle :
const users = await prisma.user.findMany();
for (const user of users) {
// One query per user: this is the N+1.
const posts = await prisma.post.findMany({ where: { authorId: user.id } });
}
Chargez plutôt la relation dans la requête d’origine :
const users = await prisma.user.findMany({
include: { posts: true },
});
Pour les lectures profondément imbriquées, la stratégie par défaut de Prisma effectue une requête par niveau de relation. Lorsqu’une seule requête avec jointure est matériellement plus rapide, l’option relationLoadStrategy: "join" indique à Prisma d’utiliser un LEFT JOIN à la place. Mesurez les performances avant de changer, car les deux stratégies arbitrent entre le nombre d’allers-retours et la duplication des colonnes parentes.
Bonnes pratiques
- Considérez
schema.prismacomme la source de vérité et modifiez-le via des migrations, jamais manuellement. - Instanciez
PrismaClientune seule fois par processus et réutilisez-le ; protégez le hot reload avec une variable globale. - Utilisez
selectpour des formes de réponse fixes etincludeuniquement lorsque vous avez besoin d’enregistrements complets. - Privilégiez la pagination par curseur aux offsets
skipvolumineux. - Utilisez
createManypour les insertions en masse et les écritures imbriquées pour les lignes liées. - Gardez les transactions interactives courtes et utilisez toujours le client
txà l’intérieur de celles-ci. - Utilisez
$queryRawavec des tagged templates, et jamais les variantes Unsafe, lorsque vous avez besoin de SQL. - Configurez
connection_limitet, en serverless, utilisez un pooler tel qu’Accelerate ou PgBouncer. - Commitez les dossiers de migration et exécutez
migrate deploydans votre CI/CD plutôt quedb push.
Erreurs courantes
- Exécuter
prisma db pushsur l’environnement de production et perdre des colonnes. - Créer un nouveau
PrismaClientpar requête et épuiser les connexions. - Oublier
prisma generateaprès un changement de schéma, puis se demander pourquoi les types ne sont pas à jour. - Récupérer des lignes complètes avec
includepour ensuite filtrer les champs en JavaScript. - Utiliser le client externe à l’intérieur d’une transaction interactive, ce qui sort silencieusement de la transaction.
- Ignorer
wheresurupdateManyoudeleteManyet mettre à jour toutes les lignes. - Supposer que
findUniqueaccepte n’importe quel filtre ; il nécessite un champ unique. - Interpoler des entrées utilisateur dans
$queryRawUnsafe. - Maintenir une transaction ouverte pendant l’attente d’un appel API externe.
Et après ?
Prisma est l’une des solutions pour interagir avec une base de données relationnelle depuis TypeScript. Comparez-le avec Drizzle, qui laisse le SQL visible et se passe de client généré, ou TypeORM, l’approche basée sur les classes et les décorateurs qui prédate les deux autres. Derrière chacun d’eux se trouve la base de données elle-même, c’est pourquoi le guide PostgreSQL apporte une profondeur technique utile quel que soit l’ORM choisi. Si vous hésitez encore sur le runtime pour le serveur qui hébergera tout cela, commencez par Node.js.