Quand on construit une API moderne en TypeScript, le choix de l’ORM change radicalement la qualité de vie du développeur. Drizzle ORM est devenu en 2026 l’option dominante pour les projets Bun et Node.js modernes : type-safe de bout en bout, pas de génération de code, syntaxe SQL-proche, support natif PostgreSQL. Combiné à Bun, c’est un duo extrêmement productif et rapide. Voici le tutoriel complet pour construire une couche d’accès aux données solide pour une application en production.
Ce tutoriel s’inscrit dans notre série Bun. Pour les bases du runtime, lisez le guide complet Bun en production 2026. Pour l’usage avec une API Hono, voir Bun + Hono : API REST type-safe.
Pourquoi Drizzle ?
- Pas de codegen : tout est déduit des types TypeScript que vous écrivez. Aucun
prisma generateou équivalent à exécuter avant chaque build. - Type-safety totale : les SELECT, JOIN, INSERT renvoient des types calculés à partir du schéma. Si vous ajoutez une colonne, TypeScript le sait immédiatement.
- Syntaxe proche du SQL :
db.select().from(users).where(eq(users.email, 'x@y.sn'))— facile à lire pour qui connaît SQL. - Léger : ~10 Ko gzippé contre 200+ Ko pour Prisma. Important pour les workers et les déploiements edge.
- Migrations gérées via
drizzle-kit: génère et applique des migrations SQL versionnables. - Excellente perf : pas de RPC interne comme Prisma, juste du SQL. Sur Bun, requêtes simples en sub-millisecond.
Prérequis
- Bun installé
- Une instance PostgreSQL (locale ou managée via Coolify)
- TypeScript de base, notion de SQL
- Niveau attendu : intermédiaire
- Temps : 1 heure
Étape 1 — Installer Drizzle
bun add drizzle-orm postgres
bun add -D drizzle-kit @types/bun
Le client postgres (de Porsager) est le client PostgreSQL recommandé : moderne, performant, compatible Bun. Alternative : node-postgres (pg) si vous voulez compatibilité Node maximale.
Étape 2 — Définir le schéma
// src/db/schema.ts
import { pgTable, serial, varchar, integer, timestamp, boolean, text } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
email: varchar("email", { length: 255 }).notNull().unique(),
name: varchar("name", { length: 100 }).notNull(),
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const posts = pgTable("posts", {
id: serial("id").primaryKey(),
authorId: integer("author_id").references(() => users.id, { onDelete: "cascade" }).notNull(),
title: varchar("title", { length: 200 }).notNull(),
content: text("content").notNull(),
published: boolean("published").default(false).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
// Types inférés automatiquement
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
Étape 3 — Initialiser le client
// src/db/client.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString, {
max: 10, // pool max
idle_timeout: 20, // secondes
connect_timeout: 10,
prepare: false, // requis pour PgBouncer transaction mode
});
export const db = drizzle(client, { schema });
L’option prepare: false est importante si vous mettez PgBouncer en transaction pooling devant Postgres. Sinon les statements préparés cassent le pool.
Étape 4 — Configuration drizzle-kit pour les migrations
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
Générer une migration depuis le schéma :
# Générer le SQL de migration
bun drizzle-kit generate
# Appliquer en local/staging
bun drizzle-kit migrate
# Voir l'état actuel de la DB vs schéma
bun drizzle-kit check
Les fichiers drizzle/0000_xxx.sql sont versionnables dans Git. En production, exécutez les migrations dans le pipeline de déploiement avant de démarrer la nouvelle version de l’app.
Étape 5 — Requêtes basiques
import { db } from "./db/client";
import { users, posts } from "./db/schema";
import { eq, and, desc, sql } from "drizzle-orm";
// SELECT *
const allUsers = await db.select().from(users);
// SELECT avec WHERE
const aissatou = await db.select().from(users).where(eq(users.email, "a@exemple.sn"));
// INSERT et retour de l'objet créé
const [newUser] = await db.insert(users).values({
email: "modou@exemple.sn",
name: "Modou",
passwordHash: await Bun.password.hash("secret"),
}).returning();
// UPDATE
await db.update(users)
.set({ name: "Modou D." })
.where(eq(users.id, newUser.id));
// DELETE
await db.delete(users).where(eq(users.id, newUser.id));
// Compter
const result = await db.select({ count: sql<number>`count(*)` }).from(users);
const total = result[0].count;
L’utilisation de Bun.password.hash() est un détail mais important : Bun a un hashing natif (Argon2id par défaut) qui évite d’installer bcrypt avec des bindings natifs.
Étape 6 — JOIN et requêtes complexes
// JOIN classique
const postsWithAuthors = await db
.select({
postId: posts.id,
title: posts.title,
authorName: users.name,
})
.from(posts)
.leftJoin(users, eq(posts.authorId, users.id))
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt))
.limit(20);
// Avec relations (alternative plus lisible)
import { relations } from "drizzle-orm";
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
// Requête avec relations
const postsWithAuthor = await db.query.posts.findMany({
where: eq(posts.published, true),
with: { author: true },
orderBy: desc(posts.createdAt),
limit: 20,
});
Étape 7 — Transactions
// Transaction atomique
await db.transaction(async (tx) => {
const [user] = await tx.insert(users).values({
email: "x@exemple.sn",
name: "X",
passwordHash: "...",
}).returning();
await tx.insert(posts).values({
authorId: user.id,
title: "Premier post",
content: "Hello",
});
// Si une exception survient ici, tout est rollback
});
Étape 8 — Indexes et performance
// Dans schema.ts, ajouter des indexes
import { index, uniqueIndex } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
email: varchar("email", { length: 255 }).notNull(),
name: varchar("name", { length: 100 }).notNull(),
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => ({
emailIdx: uniqueIndex("users_email_idx").on(table.email),
createdAtIdx: index("users_created_at_idx").on(table.createdAt),
}));
Régénérez la migration avec bun drizzle-kit generate et vérifiez le SQL produit avant de l’appliquer en prod.
Étape 9 — Seed et fixtures
// scripts/seed.ts
import { db } from "../src/db/client";
import { users, posts } from "../src/db/schema";
async function seed() {
console.log("Seeding...");
await db.delete(posts);
await db.delete(users);
const insertedUsers = await db.insert(users).values([
{ email: "demo1@exemple.sn", name: "Demo 1", passwordHash: await Bun.password.hash("demo") },
{ email: "demo2@exemple.sn", name: "Demo 2", passwordHash: await Bun.password.hash("demo") },
]).returning();
await db.insert(posts).values([
{ authorId: insertedUsers[0].id, title: "Hello", content: "World", published: true },
]);
console.log("Done.");
process.exit(0);
}
seed().catch((e) => { console.error(e); process.exit(1); });
Lancez avec bun run scripts/seed.ts.
Étape 10 — En production
- Migrations dans CI/CD : ajouter
bun drizzle-kit migratedans le pipeline avant le déploiement de la nouvelle app - Variable d’env :
DATABASE_URLseule, jamais de credentials hardcodés - Pool de connexions : ajustez
maxselon votre Postgres et le nombre de containers app - Monitoring : activer
pg_stat_statementscôté Postgres et logger les requêtes > 1 sec côté app via un wrapper Drizzle - PgBouncer : devant Postgres si vous avez beaucoup d’instances app, en transaction pooling, avec
prepare: falsecôté Drizzle
Adaptation Afrique de l’Ouest
Drizzle a un avantage particulier pour les projets ouest-africains : son footprint mémoire minimal et sa rapidité conviennent aux VPS modestes (Hetzner CX22 4 Go RAM, 4 € par mois). Sur le même hardware où Prisma + Node consomme 200-300 Mo en idle pour un service simple, Drizzle + Bun reste à 80-100 Mo. Vous pouvez héberger 4-5 services API sur le même petit VPS sans problème.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
| SSL required | Postgres distant force SSL | postgres(url, { ssl: ‘require’ }) |
| Migration en conflit | Plusieurs devs ont généré en parallèle | Resolve via fichiers SQL et meta journal |
| Type any inattendu | Schema pas importé dans drizzle() | drizzle(client, { schema }) avec import * as schema |
| Cannot find module postgres | Package non installé | bun add postgres |
| Performance dégradée avec PgBouncer | prepare: true | prepare: false dans options postgres() |
Pour aller plus loin
- Guide complet Bun en production 2026
- Bun + Hono : API REST type-safe
- Déployer Bun avec systemd ou PM2
- Documentation Drizzle : orm.drizzle.team
- Documentation postgres.js : github.com/porsager/postgres