Qu’est-ce que Drizzle ?
Drizzle est un ORM TypeScript qui considère le SQL comme un citoyen de première classe. Votre schéma est du TypeScript classique, vos requêtes sont construites à partir de méthodes qui reflètent les clauses SQL, et le runtime est suffisamment léger pour s’exécuter sur l’edge. Il n’y a pas d’étape de génération de code ni de moteur binaire : vous importez drizzle-orm et vous effectuez vos requêtes.
Cette philosophie se ressent dans chaque API. db.select().from(users).where(eq(users.id, id)) se lit dans le même ordre que le SQL qu’il produit. La bibliothèque ne cache pas la base de données, elle la type. Si vous comprenez déjà les jointures, les index et ON CONFLICT, vous comprenez déjà l’essentiel de Drizzle.
Drizzle supporte PostgreSQL, MySQL et SQLite, ainsi que des drivers serverless et edge tels que Neon, PlanetScale, Turso, Cloudflare D1 et le SQLite intégré de Bun. Le même schéma et le même style de requête s’appliquent à tous, c’est pourquoi Drizzle est devenu un choix courant pour les bases de données edge et serverless.
Votre schéma est en TypeScript
Il n’y a pas de schema.prisma ni de client généré. Vous définissez vos tables à l’aide d’un builder par dialecte et vous les exportez.
import { pgTable, text, uuid, boolean, timestamp, integer } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
name: text("name"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const posts = pgTable("posts", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
title: text("title").notNull(),
published: boolean("published").notNull().default(false),
authorId: uuid("author_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
Le premier argument est le nom de la table SQL, et chaque builder de colonne prend le nom de la colonne. Ce mappage explicite permet à la base de données d’utiliser snake_case tandis que votre code utilise camelCase. Les méthodes de colonne telles que .notNull(), .unique() et .default() ajoutent les mêmes contraintes que celles que vous écririez manuellement.
Les relations sont déclarées séparément avec relations(). Elles ne créent pas de colonnes ; elles indiquent à l’API de requête relationnelle comment les tables sont connectées.
import { relations } from "drizzle-orm";
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
Le typage est automatique
Drizzle infère les types de lignes à partir du schéma, vous n’avez donc presque jamais besoin de les écrire manuellement. Chaque table expose $inferSelect et $inferInsert :
import { users } from "./schema";
type User = typeof users.$inferSelect;
type NewUser = typeof users.$inferInsert;
function displayName(user: User) {
return user.name ?? user.email;
}
Les helpers InferSelectModel et InferInsertModel font la même chose et sont parfois plus lisibles selon les bases de code :
import type { InferInsertModel, InferSelectModel } from "drizzle-orm";
type User = InferSelectModel<typeof users>;
type NewUser = InferInsertModel<typeof users>;
Comme ces types proviennent du même objet qui construit la requête, le renommage d’une colonne met à jour tous les consommateurs instantanément. C’est tout l’avantage de maintenir le schéma en TypeScript plutôt que dans un DSL séparé : il n’y a aucun fichier généré susceptible de se désynchroniser.
Les contraintes et les index sont définis à côté des colonnes
Les règles au niveau des colonnes couvrent NOT NULL, UNIQUE et les valeurs par défaut. Les règles au niveau de la table, telles que les clés composites, les index et les contraintes de vérification (checks), sont placées dans le troisième argument optionnel, qui renvoie un tableau.
import { sql } from "drizzle-orm";
import {
check,
index,
pgTable,
primaryKey,
text,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
export const memberships = pgTable(
"memberships",
{
userId: uuid("user_id").notNull().references(() => users.id),
orgId: uuid("org_id").notNull(),
role: text("role").notNull().default("member"),
},
(table) => [
primaryKey({ columns: [table.userId, table.orgId] }),
index("memberships_org_idx").on(table.orgId),
uniqueIndex("memberships_user_org_idx").on(table.userId, table.orgId),
check("memberships_role_check", sql`${table.role} in ('member', 'admin')`),
],
);
Le fait de conserver les index dans le schéma plutôt que dans un script exécuté manuellement signifie que drizzle-kit generate les crée en même temps que la table, et que chaque environnement les reçoit dans le même ordre. Si une requête est lente, l’index dont elle a besoin doit généralement être défini ici, à côté de la colonne concernée.
Connexion à une base de données
Créez une connexion via un driver, enveloppez-la avec drizzle, et passez le schéma pour que l’API relationnelle puisse détecter vos relations.
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });
Remplacez node-postgres par le driver que vous déployez réellement : drizzle-orm/neon-http pour le point de terminaison HTTP de Neon, drizzle-orm/postgres-js, drizzle-orm/better-sqlite3, drizzle-orm/d1 ou drizzle-orm/bun-sqlite. L’API de requête utilisée ci-dessus db reste identique, et c’est là tout l’intérêt.
Drivers Serverless et Edge
La raison pour laquelle Drizzle s’adapte si bien aux environnements d’exécution edge est qu’il n’y a rien à packager en dehors du driver. Neon via HTTP et Cloudflare D1 sont les deux que vous rencontrerez le plus souvent.
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });
import { drizzle } from "drizzle-orm/d1";
export default {
async fetch(_request: Request, env: Env) {
const db = drizzle(env.DB);
const rows = await db.select().from(users);
return Response.json(rows);
},
};
Créez le client une seule fois par isolate plutôt qu’à chaque requête, et passez { schema } pour que db.query continue de fonctionner. Les drivers HTTP et D1 peuvent limiter les prepared statements ou les transactions selon le protocole ; vérifiez donc les notes du driver avant de vous appuyer sur l’un ou l’autre en production.
Le query builder se lit comme du SQL
Les requêtes sont assemblées en chaînant des méthodes qui correspondent aux clauses SQL. select, from, where, orderBy, limit et offset s’alignent ainsi un pour un avec l’instruction qu’ils génèrent.
import { eq, desc, ilike, and } from "drizzle-orm";
const rows = await db
.select({
id: posts.id,
title: posts.title,
publishedAt: posts.createdAt,
})
.from(posts)
.where(and(eq(posts.published, true), ilike(posts.title, "%drizzle%")))
.orderBy(desc(posts.createdAt))
.limit(20);
Les opérateurs sont des fonctions importées plutôt que des symboles : eq, ne, gt, gte, lt, lte, inArray, notInArray, like, ilike, isNull, isNotNull, ainsi que les combinateurs and, or et not. Lorsque vous avez besoin d’une fonctionnalité que le builder n’expose pas, sql vous permet de passer en SQL brut tout en restant paramétré.
import { sql } from "drizzle-orm";
const active = await db
.select()
.from(users)
.where(sql`${users.createdAt} > now() - interval '30 days'`);
Filtrage, tri et pagination
Les conditions se composent avec and, or et not, et les fonctions d’opérateurs couvrent les prédicats habituels. Les lire ensemble s’apparente à la lecture d’une clause WHERE.
import { and, asc, gt, inArray, isNull, or } from "drizzle-orm";
const page = await db
.select({ id: posts.id, title: posts.title, createdAt: posts.createdAt })
.from(posts)
.where(and(eq(posts.published, true), gt(posts.id, cursor)))
.orderBy(asc(posts.id))
.limit(20);
Il s’agit de la pagination par clés (keyset pagination) : au lieu d’utiliser offset, qui force la base de données à scanner et rejeter des lignes, vous paginez à partir du dernier id rencontré. Pour des ensembles de résultats plus restreints, limit et offset conviennent parfaitement, et pour les filtres basés sur des ensembles ou des valeurs nulles, le même builder s’applique :
const results = await db
.select()
.from(users)
.where(
or(
isNull(users.name),
inArray(users.email, ["[email protected]", "[email protected]"]),
),
);
Le tri utilise asc et desc sur les colonnes, ou sql pour des expressions telles que desc(sqlcount(${posts.id})). Comme chaque clause est explicite, il n’y a pas d’ordre caché ni de filtre par défaut pour vous surprendre.
Joins et agrégations
Les joins sont explicites, exactement comme en SQL. Vous choisissez innerJoin, leftJoin ou rightJoin et fournissez la condition.
const report = await db
.select({
id: users.id,
email: users.email,
postCount: sql<number>`count(${posts.id})`.mapWith(Number),
})
.from(users)
.leftJoin(posts, eq(posts.authorId, users.id))
.where(eq(posts.published, true))
.groupBy(users.id)
.orderBy(desc(sql`count(${posts.id})`))
.limit(20);
Comme vous écrivez vous-même le groupBy, il n’y a pas de plan de requête caché. Si le SQL est erroné, le TypeScript l’est aussi. Les sous-requêtes, les CTE avec with, les fonctions de fenêtrage (window functions) et les opérations d’ensemble sont toutes disponibles, soit via des helpers dédiés, soit via des fragments sql.
L’API de requêtes relationnelles
Le query builder est précis, mais s’avère verbeux pour le cas courant consistant à charger un utilisateur avec ses articles. L’API de requêtes relationnelles, db.query, se rapproche davantage de la syntaxe de Prisma :
const result = await db.query.users.findMany({
columns: { id: true, email: true },
with: {
posts: {
columns: { id: true, title: true },
where: (posts, { eq }) => eq(posts.published, true),
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
limit: 10,
},
},
});
db.query.users.findMany et findFirst exploitent le relations() que vous avez déclaré, et with les charge en un seul aller-retour par relation. Les deux API partagent le même schéma et peuvent être mélangées au sein d’un même projet : utilisez le query builder lorsque vous avez besoin de contrôle, et db.query lorsque vous souhaitez un résultat imbriqué sans avoir à écrire la jointure.
Écriture de données
Les insertions, mises à jour et suppressions sont tout aussi directes. Ajoutez .returning() pour récupérer les lignes affectées en une seule instruction, ce qui évite d’effectuer un SELECT supplémentaire.
await db
.insert(users)
.values({ email: "[email protected]", name: "Ada" })
.returning();
await db
.insert(posts)
.values([
{ title: "First", authorId },
{ title: "Second", authorId, published: true },
])
.onConflictDoNothing({ target: posts.id });
await db
.update(users)
.set({ name: "Ada Lovelace" })
.where(eq(users.id, id))
.returning();
await db.delete(posts).where(eq(posts.id, postId));
onConflictDoNothing et onConflictDoUpdate correspondent à ON CONFLICT pour les upserts. returning() est supporté sur PostgreSQL, SQLite et MariaDB ; MySQL ne le supporte pas, vous devrez donc effectuer une nouvelle sélection. L’API reflète délibérément le dialecte auquel vous êtes connecté plutôt que de prétendre que toutes les bases de données sont identiques.
Transactions
Les transactions sont des callbacks qui reçoivent un handle de transaction. Chaque requête effectuée à l’intérieur doit utiliser ce handle.
await db.transaction(async (tx) => {
await tx
.update(accounts)
.set({ balance: sql`${accounts.balance} - 5000` })
.where(eq(accounts.id, from));
await tx
.update(accounts)
.set({ balance: sql`${accounts.balance} + 5000` })
.where(eq(accounts.id, to));
});
Le fait de lever une exception (throw) à l’intérieur du callback annule la transaction (rollback). Les appels tx.transaction imbriqués deviennent des savepoints lorsque le dialecte les supporte, et vous pouvez définir un niveau d’isolation via l’objet d’options. Comme pour toute base de données, gardez le callback court et n’attendez pas (await) d’appels réseau sans rapport à l’intérieur.
Migrations avec drizzle-kit
drizzle-kit est la CLI compagnon. Elle lit drizzle.config.ts ainsi que le schéma, et gère les migrations.
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: { url: process.env.DATABASE_URL! },
});
pnpm drizzle-kit generate
pnpm drizzle-kit migrate
pnpm drizzle-kit push
pnpm drizzle-kit studio
generate compare le schéma avec le dernier snapshot, écrit un fichier SQL numéroté dans out, et enregistre un snapshot dans le journal. migrate applique les fichiers en attente dans l’ordre. Les deux sont sûrs pour la production car le SQL est un artefact révisable que vous committez.
push est le raccourci pour le prototypage : il aligne la base de données sur le schéma sans écrire de fichiers de migration. C’est pratique en développement, mais risqué en production pour les mêmes raisons que n’importe quelle commande de schema-push. pull fait l’inverse en introspectant une base de données existante vers TypeScript, et studio ouvre un navigateur de données local. Pour les drivers HTTP uniquement comme Neon, utilisez le migrateur du driver plutôt que la CLI :
import { migrate } from "drizzle-orm/neon-http/migrator";
await migrate(db, { migrationsFolder: "./drizzle" });
Requêtes préparées (Prepared statements)
Drizzle peut préparer une requête une seule fois et l’exécuter plusieurs fois avec des paramètres différents, ce qui permet d’économiser les ressources liées à la planification sur les chemins critiques (hot paths).
const getUser = db
.select()
.from(users)
.where(eq(users.id, sql.placeholder("id")))
.prepare("get_user");
const user = await getUser.execute({ id: userId });
Les placeholders sont déclarés avec sql.placeholder("name") et fournis lors de l’exécution. Les requêtes préparées sont nommées afin que le driver puisse mettre le plan en cache ; gardez à l’esprit que les poolers de connexion en mode transaction, comme PgBouncer, peuvent ne pas les préserver d’une connexion à l’autre.
Un seul ORM, plusieurs bases de données
Comme le query builder reflète le SQL, Drizzle s’adapte à chaque dialecte au lieu de les uniformiser. Vous importez les constructeurs de colonnes depuis drizzle-orm/pg-core, drizzle-orm/mysql-core ou drizzle-orm/sqlite-core, et les types ainsi que les fonctions disponibles s’adaptent à la base de données.
Cela signifie que les fonctionnalités spécifiques à PostgreSQL, telles que jsonb, les tableaux, pgEnum et les colonnes générées, sont traitées comme des citoyens de première classe, tandis que MySQL et SQLite disposent de leurs propres équivalents. La contrepartie est que le transfert d’un schéma entre différents dialectes demande un effort réel : les abstractions sont partagées, mais pas le SQL. C’est un compromis délibéré pour les équipes qui privilégient les capacités réelles de leur base de données plutôt qu’un plus petit dénominateur commun.
Pourquoi on a l’impression d’écrire du SQL
La plupart des ORM masquent la base de données pour exposer des objets. Drizzle fait l’inverse : il vous apporte des types autour de la base de données que vous connaissez déjà. Cela entraîne trois conséquences.
Premièrement, le débogage est plus simple. La requête que vous écrivez est celle qui est exécutée ; ainsi, la lecture d’un log de requête lente ou d’une sortie EXPLAIN renvoie directement au code. Deuxièmement, l’apprentissage est transférable, car vos connaissances sur les jointures, les index et ON CONFLICT s’appliquent sans changement. Troisièmement, il y a moins de “magie” pour vous surprendre : pas de proxys de lazy-loading qui déclenchent des requêtes dès que vous accédez à une propriété, et pas d’identity map pour décider de ce qui est déjà en mémoire.
Le revers de la médaille est qu’il est attendu que vous maîtrisiez le SQL. Drizzle générera volontiers une requête inefficace si vous lui en demandez une, et il ne vous sauvera pas d’un index manquant.
Performance et taille du bundle
Drizzle n’embarque aucun moteur Rust ni aucun client généré. Le runtime est du TypeScript pur compatible avec le tree-shaking ; ainsi, une fonction serverless ou un edge worker ne transporte que le query builder et le driver. Il n’y a aucun binaire séparé à inclure dans le bundle ou à démarrer à froid (cold-start), ce qui explique pourquoi les plateformes edge le privilégient.
Côté serveur, le principe est le même que pour tout ORM : c’est la base de données qui effectue le travail, et la structure de vos requêtes détermine la performance. Utilisez .returning() pour éviter les allers-retours superflus, préparez vos requêtes fréquentes, sélectionnez uniquement les colonnes nécessaires et laissez les index faire leur travail. Comme rien n’est mis en cache ou regroupé en arrière-plan, la performance que vous mesurez est celle du SQL que vous avez écrit.
Bonnes pratiques
- Considérez
schema.tscomme l’unique source de vérité et exportez les tables et les relations depuis un seul endroit. - Utilisez
drizzle-kit generateet commitez le SQL ; ne laissez jamaispushtoucher la production. - Sélectionnez uniquement les colonnes dont vous avez besoin, et ajoutez
.returning()plutôt que d’effectuer une lecture supplémentaire. - Privilégiez
db.queryavecwithpour les lectures imbriquées et le query builder pour tout besoin personnalisé. - Enveloppez les écritures en plusieurs étapes dans des
db.transactionet utilisez le handletxtout au long du processus. - Utilisez des placeholders
sqlet des requêtes préparées (prepared statements) sur les chemins critiques. - Adaptez le driver à la plateforme et partagez une seule instance
dbpar processus. - Ajoutez les index dans le schéma à côté des colonnes qu’ils couvrent.
- Exécutez les migrations dans la CI/CD avant le déploiement du nouveau code.
Erreurs courantes
- Appeler
drizzle-kit pushsur une base de données de production et supprimer des colonnes. - Utiliser le
dbexterne à l’intérieur d’une transaction au lieu dutxfourni. - Oublier de passer
{ schema }àdrizzle, ce qui cassedb.query. - Écrire des conditions
whereavec le mauvais opérateur, commeeqalors queinArrayest nécessaire. - Sélectionner toutes les colonnes par défaut dans les chemins critiques (hot paths).
- Omettre les index sur les clés étrangères utilisées par les jointures.
- Supposer que MySQL supporte
.returning(). - Considérer les fragments
sqlcomme étant automatiquement sécurisés lorsqu’ils interpolent des chaînes de caractères utilisateur. - Oublier de lancer
generateaprès avoir modifié le schéma, puis se demander pourquoi la migration est vide.
Et après ?
Drizzle valorise la connaissance de la base de données sous-jacente ; nous vous conseillons donc de lire le guide PostgreSQL pour en savoir plus sur les index, les transactions et la planification des requêtes. Vous pouvez également le comparer à Prisma, qui génère un client à partir d’un schéma, ou à TypeORM, qui modélise les entités sous forme de classes. Si vous apprenez encore le langage lui-même, le guide TypeScript constitue le socle sur lequel reposent ces trois outils.