Accueil / Articles

Passer de Prisma à Drizzle : un bilan après six mois

Six mois après avoir transféré une stack PostgreSQL TypeScript de Prisma à Drizzle ORM : résultats de benchmarks réels, ce qui s’est amélioré immédiatement, et les compromis qui ont posé des problèmes.

Éditeur : REACTAPP.TOP10 min de lecture2403 mots

Sources: I replaced Prisma with Drizzle. Six months later, here’s what I gained and what I missed.

Il y a environ huit mois, un doute persistant concernant Prisma s’est finalement concrétisé en quelque chose qui justifiait une action. Ce n’était pas que quelque chose fût complètement défaillant : la friction s’était simplement accumulée progressivement au fil du temps jusqu’à devenir impossible à ignorer. L’architecture comprenait plusieurs petites applications, toutes fonctionnant sous PostgreSQL et écrites en TypeScript, derrière un petit nombre de services API. Rien d’anormal ou de révolutionnaire. Prisma faisait ce pour quoi il était conçu. Néanmoins, quelques problèmes récurrents continuaient de surgir.

Le fichier @prisma/client généré était énorme. Lorsqu’il était exécuté via des fonctions serverless, les démarrages en froid étaient particulièrement lents car le client devait d’abord être chargé. L’étape prisma generate dans le pipeline CI commençait à ressembler à un coût à payer à chaque fusion de code. De plus, chaque fois qu’une requête brute était nécessaire pour quelque chose de plus complexe que ce que l’ORM pouvait gérer, le type de retour était unknown[], ce qui ne fournissait aucune sécurité de type et obligeait à effectuer des conversions manuelles partout.

Finalement, il a été décidé de passer à Drizzle ORM, et la migration a été achevée il y a environ six mois. Ce qui suit est un aperçu honnête de la manière dont cela s’est déroulé.

Pourquoi j’ai d’abord envisagé Drizzle

Le problème de la taille du paquet a été le premier véritable déclencheur pour prendre Drizzle au sérieux. Au moment de la migration, le client Prisma seul ajoutait près de 14 MB au paquet de déploiement Lambda, le binaire du moteur de requêtes Rust représentant à lui seul plusieurs de ces mégaoctets. Les points d’accès peu fréquentés présentaient des temps de démarrage lents de 2 à 3 secondes — un chiffre qui se remarque immédiatement sur un tableau de bord de latence.

En comparaison, Drizzle pèse environ 7,4 KB une fois minifié et compressé, sans aucune dépendance en temps de exécution. Pas de binaire compilé, pas de processus moteur distinct, aucune étape de sérialisation entre votre code TypeScript et la connexion à la base de données.

La deuxième source de friction était l’étape de génération du code. Toute modification du schéma nécessitait d’exécuter prisma generate avant que TypeScript ne reconnaisse la nouvelle structure. En pratique, cela entraînait l’un de deux résultats négatifs : soit on l’exécutait manuellement et on oubliait parfois de commiter le client régénéré, soit on l’intégrait à chaque exécution CI, ce qui ajoutait 15 à 20 secondes supplémentaires par build. Aucune de ces options n’était satisfaisante.

Avec Drizzle, le schéma lui-même est simplement du TypeScript. En modifiant le fichier de schéma, les types sont mis à jour immédiatement — il n’y a pas d’étape de génération intermédiaire, et le compilateur prend en compte la modification aussitôt.

Le troisième facteur déterminant était la manière dont SQL brut était géré. Prisma le traite purement comme une solution de secours : l’appel à $queryRaw renvoie unknown[], ce qui nécessite soit un cast manuel, soit un schéma Zod pour en assurer l’utilisation sécurisée. En revanche, la balise de template sql de Drizzle permet de déclarer directement la structure souhaitée — quelque chose comme sql<{ id: number; total: number }> — et on obtient exactement ce type en retour, sans besoin de cast ni de couche de validation supplémentaire.

À quoi ressemblait la migration

Pour illustrer, considérez trois tables : users, products et orders. Avec Prisma, elles étaient définies dans un seul fichier schema.prisma :

model User {
  id        String   @id @default(uuid())
  email     String   @unique
  name      String
  orders    Order[]
  createdAt DateTime @default(now())
}

model Product {
  id          String      @id @default(uuid())
  name        String
  priceInCents Int
  orders      OrderItem[]
}

model Order {
  id        String      @id @default(uuid())
  userId    String
  user      User        @relation(fields: [userId], references: [id])
  items     OrderItem[]
  createdAt DateTime    @default(now())
}

model OrderItem {
  id        String  @id @default(uuid())
  orderId   String
  order     Order   @relation(fields: [orderId], references: [id])
  productId String
  product   Product @relation(fields: [productId], references: [id])
  quantity  Int
}

Avec Drizzle, le même schéma devient un fichier TypeScript :

import { pgTable, uuid, text, integer, timestamp } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';\

export const users = pgTable('users', {
  id: uuid('id').defaultRandom().primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

export const products = pgTable('products', {
  id: uuid('id').defaultRandom().primaryKey(),
  name: text('name').notNull(),
  priceInCents: integer('price_in_cents').notNull(),
});

export const orders = pgTable('orders', {
  id: uuid('id').defaultRandom().primaryKey(),
  userId: uuid('user_id').notNull().references(() => users.id),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

export const orderItems = pgTable('order_items', {
  id: uuid('id').defaultRandom().primaryKey(),
  orderId: uuid('order_id').notNull().references(() => orders.id),
  productId: uuid('product_id').notNull().references(() => products.id),
  quantity: integer('quantity').notNull(),
});

export const usersRelations = relations(users, ({ many }) => ({
  orders: many(orders),
}));

export const ordersRelations = relations(orders, ({ one, many }) => ({
  user: one(users, { fields: [orders.userId], references: [users.id] }),
  items: many(orderItems),
}));

export const orderItemsRelations = relations(orderItems, ({ one }) => ({
  order: one(orders, { fields: [orderItems.orderId], references: [orders.id] }),
  product: one(products, { fields: [orderItems.productId], references: [products.id] }),
}));

Oui, cela prend un peu plus de temps. Mais c’est du TypeScript du début à la fin. Votre éditeur comprend chaque champ du schéma, l’autocomplétion fonctionne directement dessus, et vous pouvez importer des parties du schéma directement dans la logique de votre application.

Un fichier drizzle.config.ts rassemble tout cela :

import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  dialect: 'postgresql',
  schema: './src/schema.ts',
  out: './drizzle/migrations',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  strict: true,
});

Faites attention : défendez toujours strict: true. Si vous l’omettez, Drizzle pourrait interpréter un changement de nom de colonne comme « supprimer la vieille colonne et en créer une nouvelle » — un changement qui détruit silencieusement les données jusqu’à ce que vous examiniez le SQL généré pour comprendre ce qui s’est passé. En activant strict: true, Drizzle s’arrête et demande une confirmation chaque fois qu’un changement est ambigu.

Avec le schéma en place, générer une migration ne nécessite qu’une seule commande :

bunx drizzle-kit generate

Cela génère un fichier SQL à l’intérieur de ./drizzle/migrations. Vous l’examinez, vous vous assurez qu’il fait ce à quoi vous vous attendiez, puis vous l’appliquez :

bunx drizzle-kit migrate

Ce fichier SQL est intégré au système de contrôle de version juste à côté du schéma. Toute l’histoire des migrations est représentée par du SQL simple et lisible, plutôt que par un format de journal qui n’a de sens qu’avec les outils propres à Prisma.

Ce qui s’est amélioré immédiatement

Bundles plus petits, démarrages en froid plus rapides. Cela était évident dès le début et facile à mesurer. La taille de nos bundles Lambda a diminué de plus de 90 pour cent. Les fonctions qui nécessitaient auparavant plus de deux secondes pour démarrer en froid le font maintenant en moins de 400 ms. Les tâches en arrière-plan n’ont pas bénéficié réellement de cela, mais nos fonctions API se sont nettement accélérées.

SQL brut tapé à la main. Cela s’est avéré utile dès le deuxième jour de la migration. Nous avions une requête de rapportage qui joignait quatre tables avec des agrégations que le constructeur de requêtes Prisma ne pouvait tout simplement pas exprimer de manière propre. La solution de secours de Prisma pour ce type de requête ressemblait à ceci :

// Prisma - returns unknown[], no type safety
const revenue = await prisma.$queryRaw`
  SELECT p.id, p.name, SUM(oi.quantity * p.price_in_cents) as total_revenue
  FROM products p
  JOIN order_items oi ON p.id = oi.product_id
  GROUP BY p.id, p.name
  ORDER BY total_revenue DESC
`;

Avec Drizzle, la requête équivalente est renvoyée avec un type réel associé :

import { sql } from 'drizzle-orm';

interface ProductRevenue {
  id: string;
  name: string;
  totalRevenue: number;
}

const revenue = await db.execute(sql<ProductRevenue>`
  SELECT p.id, p.name, SUM(oi.quantity * p.price_in_cents) AS total_revenue
  FROM products p
  JOIN order_items oi ON p.id = oi.product_id
  GROUP BY p.id, p.name
  ORDER BY total_revenue DESC
`);

// revenue.rows typed as ProductRevenue[] at compile-time; validate at runtime if needed

Vous écrivez toujours l’interface manuellement au lieu de la laisser être déduite automatiquement, mais c’est un petit inconvénient par rapport à l’usage répandu de conversions unknown dans tout le codebase.

Drizzle Studio. L’exécution de bunx drizzle-kit studio lance un navigateur de base de données locale. Il est moins abouti que Prisma Studio, mais il est rapide, fonctionnel et ne nécessite ni compte Prisma ni connexion au cloud. Pour le travail local quotidien, cette indépendance a de la valeur.

Ce qui a nécessité des ajustements

Les relations exigent une configuration plus explicite. Dans Prisma, récupérer une commande ainsi que son utilisateur et ses articles était aussi simple que d’utiliser include:

// Prisma - nested include
const order = await prisma.order.findUnique({
  where: { id: orderId },
  include: {
    user: true,
    items: {
      include: { product: true }
    }
  }
});

L’API de requêtes relationnelles de Drizzle permet d’obtenir un résultat similaire :

import { db } from './db';
import { orders, users, orderItems, products } from './schema';

const order = await db.query.orders.findFirst({
  where: (orders, { eq }) => eq(orders.id, orderId),
  with: {
    user: true,
    items: {
      with: { product: true }
    }
  }
});

La forme finale des deux requêtes est similaire. Le problème, c’est que la syntaxe with ne fonctionne qu’une fois que vous avez défini les appels relations() dans votre schéma, ceux-là mêmes décrits précédemment. Lorsque nous avons fait le changement pour la première fois, nous n’avions pas encore terminé de les configurer, et nous avons passé l’après-midi à essayer de comprendre pourquoi with ne renvoyait rien. Une fois que les relations sont correctement déclarées, tout fonctionne comme prévu — mais cette étape de configuration initiale est gérée implicitement par Prisma à partir uniquement du schéma, sans nécessiter de configuration supplémentaire.

La génération de données de départ est désormais une tâche manuelle. Prisma intègre une convention de génération de données de départ : placez un fichier seed.ts à l’endroit approprié, exécutez prisma db seed, et c’est fait. Drizzle ne dispose d’aucune commande équivalente intégrée. Au lieu de cela, vous devez écrire un script TypeScript ordinaire et l’exécuter vous-même :

import { db } from './db';
import { users, products } from './schema';

async function seed() {
  await db.insert(users).values([
    { email: 'ana.silva@acme.com', name: 'Ana Silva' },
    { email: 'carlos.reyes@acme.com', name: 'Carlos Reyes' },
  ]);

 await db.insert(products).values([
    { name: 'Starter Plan', priceInCents: 2900 },
    { name: 'Pro Plan', priceInCents: 7900 },
    { name: 'Enterprise Plan', priceInCents: 19900 },
  ]);

  console.log('Seed complete');
  process.exit(0);
}
seed().catch(console.error);

Et vous l’configurez dans package.json:

{
  "scripts": {
    "db:seed": "bun run src/seed.ts"
  }
}

Rien de tout cela n’est difficile, et on peut même dire que c’est plus transparent que la convention implicite de Prisma. Cependant, tout outil ou étape CI existant qui appelait directement prisma db seed devra être réécrit pour appeler votre propre script à la place.

Comparaison directe des deux outils. Drizzle Studio fait le travail. Prisma Studio est simplement plus agréable à utiliser et offre davantage : des contrôles de filtrage intégrés, une pagination, ainsi qu’un éditeur de table plus soigné. En revanche, Drizzle Studio semble rudimentaire. Six mois après avoir commencé à l’utiliser, nous dépendons moins des deux outils que prévu, car la plupart de l’exploration des données réelles se fait via un client de base de données dédié, quel que soit l’ORM utilisé.

Le seul cas où Prisma reste supérieur

Écritures imbriquées. Lorsque vous devez insérer une commande ainsi que ses articles en une seule étape, Prisma s’en charge de manière propre :

// Prisma - nested create in one call
const order = await prisma.order.create({
  data: {
    userId: user.id,
    items: {
      create: [
        { productId: starterPlanId, quantity: 1 },
        { productId: proPlanId, quantity: 2 },
      ]
    }
  },
  include: { items: true }
});

Drizzle ne dispose pas d’équivalent pour les créations imbriquées. Vous devez donc écrire vous-même la logique à l’intérieur d’une transaction explicite :

import { db } from './db';
import { orders, orderItems } from './schema';

const newOrder = await db.transaction(async (tx) => {
  const [order] = await tx
    .insert(orders)
    .values({ userId: user.id })
    .returning();

  await tx.insert(orderItems).values([
    { orderId: order.id, productId: starterPlanId, quantity: 1 },
    { orderId: order.id, productId: proPlanId, quantity: 2 },
  ]);

  return order;
});

Cela implique évidemment plus de code à écrire et à maintenir. Si votre application repose fortement sur ce type de schéma, le code supplémentaire s’accumule rapidement. La version proposée par Prisma reste plus concise et communique l’intention de manière plus directe. Si les mutations imbriquées apparaissent fréquemment dans votre modèle de domaine, choisir Prisma est une décision tout à fait raisonnable.

Décider s’il convient de passer à Prisma

Envisagez de passer à Drizzle si l’un des points suivants s’applique : vous déployez sur des plateformes serverless ou edge où la taille du bundle et le temps de démarrage ont un impact direct sur les coûts ou l’expérience utilisateur ; vous écrivez fréquemment des requêtes SQL analytiques complexes et souhaitez y accéder directement ; votre équipe maîtrise suffisamment SQL pour qu’une couche d’abstraction n’ajoute pas beaucoup de valeur ; ou vous souhaitez que les migrations soient exprimées sous forme de fichiers SQL simples que n’importe quel ingénieur, quel que soit son niveau de connaissance des ORM, puisse lire et examiner.

Pensez à rester avec Prisma si : vous travaillez sur un prototype ou une outil interne où la rapidité de déploiement est plus importante que des économies de quelques millisecondes au niveau du temps d’exécution ; votre application effectue de nombreuses opérations d’écriture et de lecture avec des relations profondément imbriquées ; une partie de votre équipe n’est pas à l’aise avec le SQL brut et bénéficie du style de modélisation déclarative de Prisma ; ou vous avez déjà migré vers Prisma 7, où le problème historique lié à la taille du bundle est beaucoup moins grave.

Ici aussi, le timing est crucial. Prisma 7, sorti à la fin de 2025, a complètement éliminé le moteur Rust. Sa taille actuelle est d’environ 1,6 MB — ce qui reste plus de 200 fois supérieur à celle de Drizzle, mais loin des 14 MB du fichier binaire qui poussait initialement les utilisateurs vers d’autres solutions. Si la taille du fichier était le seul motif qui vous poussait à envisager ce changement, il vaut la peine de vérifier si ce motif reste valable compte tenu de l’allègement considérable de Prisma.

Pour l’équipe responsable de cette migration, la réponse était toujours oui. De meilleures typages pour les requêtes brutes et l’absence d’étape de génération de code justifiaient à elles seules ce changement, indépendamment de toute économie de taille. Six mois plus tard, le choix s’avère toujours pertinent. Cela dit, ce n’est pas la réponse universellement correcte — quiconque prétend qu’un ORM est objectivement supérieur pour tous les cas d’usage ne vous dit pas toute la vérité.

Prochaines étapes si vous souhaitez l’explorer par vous-même

Si vous souhaitez expérimenter Drizzle avec une base de données que vous possédez déjà, sans vous engager dans une réécriture complète, les outils d’inspection du Drizzle Kit permettent à drizzle-kit pull de lire votre schéma existant et de générer automatiquement les définitions TypeScript correspondantes. Cela vous offre une base de travail fonctionnelle, au lieu de devoir écrire manuellement chaque définition de table à partir de zéro.

La documentation officielle de Drizzle ORM explique en détail le constructeur de requêtes relationnelles, l’opérateur de template sql, ainsi que l’ensemble complet des commandes de migration. Il vaut également la peine de lire la page de comparaison de Prisma avec Drizzle disponible sur leur site — elle présente de manière équitable les compromis du point de vue de Prisma, ce qui constitue une honnêteté supérieure à celle offerte par la plupart des comparaisons entre fournisseurs.