Développement Web

Bun + Drizzle ORM avec PostgreSQL : tutoriel complet 2026

11 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 (informations vérifiées en avril 2026, susceptibles d’évoluer) 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 pratique 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 CX23 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 approfondir

Étape 1 : pourquoi Bun + Drizzle pour PostgreSQL en 2026

Bun 1.2 est sorti en 2025 avec un runtime 3 à 4 fois plus rapide que Node.js sur du I/O, et un installeur de paquets plus rapide que pnpm. Drizzle ORM est un ORM TypeScript-first, sans génération de code, qui produit du SQL transparent. Le combo est idéal pour une API REST moderne hébergée sur VPS Hetzner ou Scaleway.

Pour une PME Dakar ou Abidjan qui démarre une plateforme métier, ce stack tient sans problème 500 req/s sur un VPS 4 EUR/mois (~2 624 FCFA). Inutile de surdimensionner avec Kubernetes la première année.

Étape 2 : installer Bun sur Linux ou macOS

Une seule ligne suffit. Bun installe son runtime, son gestionnaire de paquets et son bundler.

curl -fsSL https://bun.sh/install | bash
bun --version

Sortie attendue : 1.2.x ou supérieur. Si la commande n est pas trouvée, ouvrez un nouveau terminal pour recharger le PATH. Sur Windows, utilisez WSL2 plutôt que la version Windows native moins stable.

Étape 3 : initialiser un projet et installer Drizzle

Créez le squelette avec bun init puis ajoutez Drizzle et le driver Postgres.

mkdir api-bun && cd api-bun
bun init -y
bun add drizzle-orm postgres
bun add -d drizzle-kit @types/bun

Le fichier package.json doit lister drizzle-orm et postgres en dépendances. Comptez 8 à 15 secondes d installation, beaucoup plus rapide que npm classique.

Étape 4 : provisionner PostgreSQL local en Docker

Pour le dev, lancez Postgres 17 en conteneur. En production, préférez un Postgres managé (Scaleway 7 EUR/mois, Neon free tier, Supabase free tier).

docker run -d --name pg-dev \
  -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=app \
  -p 5432:5432 postgres:17-alpine

Vérifiez avec docker logs pg-dev | grep "ready to accept". Si vous voyez ce message, la base est joignable sur localhost:5432.

Étape 5 : définir le schéma Drizzle

Créez src/db/schema.ts. Drizzle utilise du TypeScript pur, sans DSL maison.

import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core';

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

Cette définition génère ensuite la migration SQL. L approche schema-as-code est idéale pour un workflow Git : le schéma vit dans le repo, les pull requests sont auditables.

Étape 6 : générer et appliquer la première migration

Drizzle-kit produit le SQL automatiquement à partir du schéma TypeScript.

bunx drizzle-kit generate
bunx drizzle-kit migrate

Le dossier drizzle/ contient un fichier 0000_xxx.sql. Si la migration s applique sans erreur, la table users existe dans Postgres. Vérifiez avec docker exec -it pg-dev psql -U postgres -d app -c "\dt".

Étape 7 : créer un client Drizzle réutilisable

Centralisez la connexion dans src/db/client.ts pour éviter d ouvrir 10 pools.

import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';

const queryClient = postgres(process.env.DATABASE_URL!);
export const db = drizzle(queryClient);

Importez ce db partout dans l application. Bun charge .env automatiquement, pas besoin de dotenv. Cette simplicité fait gagner du temps sur des projets multi-environnements.

Étape 8 : écrire un serveur HTTP avec Bun.serve

Bun expose une API serveur native, sans Express. Pour une API CRUD simple, c est largement suffisant.

import { db } from './db/client';
import { users } from './db/schema';

Bun.serve({
  port: 3000,
  async fetch(req) {
    if (req.method === 'GET' && new URL(req.url).pathname === '/users') {
      const all = await db.select().from(users);
      return Response.json(all);
    }
    return new Response('Not found', { status: 404 });
  }
});

Lancez avec bun src/server.ts puis testez curl http://localhost:3000/users. Sortie : [] tant qu aucun utilisateur n est créé.

Étape 9 : ajouter validation, logs et erreurs

Validez les entrées avec Zod (bun add zod) avant chaque insert. Loggez les requêtes lentes (>200 ms) dans un fichier ou Sentry. Renvoyez des codes HTTP 400 pour entrée invalide, 409 pour doublon email, 500 pour erreur serveur inattendue.

Cette discipline évite des heures de debug en production. Un client qui voit toujours 500 Internal Server Error sans détail abandonne. Sur un angle proche, voyez nos tutoriels programmation et nos articles DevOps.

Étape 10 : déployer en production avec PM2 ou systemd

Sur VPS, écrivez un service systemd qui lance bun src/server.ts au boot et redémarre en cas de crash.

[Service]
ExecStart=/root/.bun/bin/bun /opt/api-bun/src/server.ts
Restart=always
Environment=DATABASE_URL=postgres://...

Reverse-proxy via Caddy ou nginx pour HTTPS Let s Encrypt automatique. Sauvegardez Postgres chaque nuit avec pg_dump vers un bucket S3 distant. Cette base permet de scaler sereinement le projet pendant ses 18 premiers mois sans refonte.

Étape 11 : observer et profiler en production

Activez le mode --inspect de Bun pour profiler à chaud quand une requête lente apparaît. Branchez Prometheus + Grafana via un middleware exposant /metrics au format OpenMetrics. Comptez 30 minutes de setup pour visualiser p50, p95, p99 latence et taux d erreur en temps réel.

Sur Drizzle, activez le logger en dev (drizzle(queryClient, { logger: true })) pour voir le SQL exact généré. Une requête mal indexée saute aux yeux. Ajoutez les index manquants via une nouvelle migration : CREATE INDEX idx_users_email ON users(email);. Cette boucle observe-corrige tient l API performante même quand le trafic décuple en saison commerciale.

Partager