ITSkillsCenter
Développement Web

Bun + Drizzle ORM avec PostgreSQL : tutoriel complet 2026

7 min de lecture

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 generate ou é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 migrate dans le pipeline avant le déploiement de la nouvelle app
  • Variable d’env : DATABASE_URL seule, jamais de credentials hardcodés
  • Pool de connexions : ajustez max selon votre Postgres et le nombre de containers app
  • Monitoring : activer pg_stat_statements cô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: false cô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

ErreurCauseSolution
SSL requiredPostgres distant force SSLpostgres(url, { ssl: ‘require’ })
Migration en conflitPlusieurs devs ont généré en parallèleResolve via fichiers SQL et meta journal
Type any inattenduSchema pas importé dans drizzle()drizzle(client, { schema }) avec import * as schema
Cannot find module postgresPackage non installébun add postgres
Performance dégradée avec PgBouncerprepare: trueprepare: false dans options postgres()

Pour aller plus loin

Besoin d'un site web ?

Confiez-nous la Création de Votre Site Web

Site vitrine, e-commerce ou application web — nous transformons votre vision en réalité digitale. Accompagnement personnalisé de A à Z.

À partir de 250.000 FCFA
Parlons de Votre Projet
Publicité