Drizzle ORM avec PostgreSQL : Guide complet
Drizzle ORM occupe une position unique dans l'écosystème de base de données TypeScript : c'est l'ORM qui n'est en fait qu'un générateur de requêtes déguisé, avec un schéma en tant que code qui génère du SQL que vous pouvez lire et un système de migration qui traite votre base de données comme la source de vérité. Après des années de Prisma et TypeORM, Drizzle a l'impression de revenir à la maison avec du SQL brut, mais avec une inférence TypeScript complète et une surcharge d'exécution nulle.
Ce guide couvre tout, de la configuration initiale aux modèles de production, en s'appuyant sur une base de code contenant plus de 65 fichiers de schéma Drizzle, des requêtes multi-locataires et un modèle de connexion proxy paresseux qui fonctionne de manière fiable dans les contextes Next.js et NestJS.
Points clés à retenir
- Utilisez un proxy paresseux pour la connexion à la base de données - ne créez jamais de connexions hâtives au moment du chargement du module
- PostgreSQL local doit s'exécuter sur un port autre que celui par défaut (5433) pour éviter les conflits avec les installations du système
- Toutes les tables ont besoin de
organizationIdpour la multi-location - appliquée au niveau du schéma- Les comparaisons d'énumérations Drizzle nécessitent un casting TypeScript explicite pour le rétrécissement du type
- N'utilisez jamais
sql.raw()— utilisez toujours le modèle littéralsqlavec des valeurs paramétrées- Les relations sont séparées des clés étrangères — définissez les deux pour une sécurité de type totale
- Les transactions enveloppent des opérations en plusieurs étapes ; transmettre l'objet
txaux services- L'index de schéma (
schema/index.ts) doit exporter toutes les tables pour que les migrations fonctionnent
Installation et configuration
Drizzle nécessite deux packages : l'ORM principal et le pilote de base de données.
pnpm add drizzle-orm pg
pnpm add -D drizzle-kit @types/pg
Pour la pile complète incluant les migrations et le studio :
pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit
Le package postgres (différent de pg) est le pilote recommandé pour Drizzle dans les projets TypeScript modernes : il prend en charge le regroupement de connexions, les instructions préparées et possède de meilleurs types TypeScript.
Le modèle de proxy paresseux
La décision architecturale la plus critique avec Drizzle dans un monorepo Next.js ou NestJS est de ne jamais créer de connexion à une base de données impatiente. Les connexions hâtives au moment du chargement du module provoquent des problèmes dans :
- Next.js : les modules sont importés pendant le processus de construction, où aucune base de données n'est disponible
- NestJS : les importations de packages ont lieu avant le chargement des variables d'environnement
- Sans serveur : les démarrages à froid sont retardés par la surcharge de connexion
Le modèle de proxy paresseux résout tous ces problèmes :
// packages/db/src/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
let _db: ReturnType<typeof drizzle> | null = null;
function getDb() {
if (!_db) {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is not set');
}
const client = postgres(connectionString, {
max: 10, // connection pool size
idle_timeout: 20,
connect_timeout: 10,
});
_db = drizzle(client, { schema });
}
return _db;
}
// Export a Proxy that initializes the connection on first use
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
get(_, prop) {
return getDb()[prop as keyof ReturnType<typeof drizzle>];
},
});
export * from './schema';
Ce modèle signifie que l'importation de @ecosire/db n'ouvre jamais de connexion à une base de données. La connexion est créée uniquement lors de l'exécution de la première requête.
Conception de schéma
Les schémas Drizzle sont des fichiers TypeScript simples qui exportent des définitions de table. La discipline ici est de conserver les tables associées dans le même fichier et de tout exporter via un index central.
// packages/db/src/schema/contacts.ts
import {
pgTable,
uuid,
text,
varchar,
timestamp,
pgEnum,
boolean,
index,
} from 'drizzle-orm/pg-core';
export const contactTypeEnum = pgEnum('contact_type', ['individual', 'company', 'partner']);
export const contacts = pgTable(
'contacts',
{
id: uuid('id').defaultRandom().primaryKey(),
organizationId: uuid('organization_id').notNull(), // Multi-tenancy
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }),
phone: varchar('phone', { length: 50 }),
type: contactTypeEnum('type').default('individual').notNull(),
isActive: boolean('is_active').default(true).notNull(),
notes: text('notes'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
organizationIdx: index('contacts_organization_idx').on(table.organizationId),
emailIdx: index('contacts_email_idx').on(table.email),
})
);
export type Contact = typeof contacts.$inferSelect;
export type NewContact = typeof contacts.$inferInsert;
Les types $inferSelect et $inferInsert vous offrent une inférence TypeScript complète — Drizzle sait quels champs sont obligatoires et facultatifs au moment de l'insertion en fonction des contraintes de votre schéma.
// packages/db/src/schema/index.ts
export * from './contacts';
export * from './orders';
export * from './licenses';
export * from './products';
// ... all tables
Le fichier d'index doit exporter chaque table pour drizzle-kit afin de les récupérer lors de la génération de la migration.
Relations
Drizzle sépare les contraintes de clé étrangère (appliquées par PostgreSQL) des définitions de relation (utilisées par l'API de requête relationnelle de Drizzle). Il vous faut les deux :
// packages/db/src/schema/relations.ts
import { relations } from 'drizzle-orm';
import { contacts } from './contacts';
import { orders } from './orders';
import { orderItems } from './order-items';
import { products } from './products';
export const contactsRelations = relations(contacts, ({ many }) => ({
orders: many(orders),
}));
export const ordersRelations = relations(orders, ({ one, many }) => ({
contact: one(contacts, {
fields: [orders.contactId],
references: [contacts.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],
}),
}));
Une fois les relations définies, vous pouvez utiliser l'API de requête relationnelle de Drizzle :
// Fetch order with contact and items in one query
const order = await db.query.orders.findFirst({
where: eq(orders.id, orderId),
with: {
contact: true,
items: {
with: {
product: true,
},
},
},
});
Drizzle génère une seule requête JOIN – pas de problème N+1.
Requêtes de type sécurisé
L'API de requête de Drizzle est entièrement sécurisée. Le type de sélection se restreint en fonction des colonnes que vous incluez :
import { db } from '@ecosire/db';
import { contacts } from '@ecosire/db/schema';
import { eq, and, like, sql, count } from 'drizzle-orm';
// Full select — returns Contact[]
const allContacts = await db
.select()
.from(contacts)
.where(eq(contacts.organizationId, orgId))
.limit(50);
// Partial select — returns only specified columns
const contactNames = await db
.select({
id: contacts.id,
name: contacts.name,
email: contacts.email,
})
.from(contacts)
.where(
and(
eq(contacts.organizationId, orgId),
eq(contacts.isActive, true)
)
);
// Count query
const [{ total }] = await db
.select({ total: count() })
.from(contacts)
.where(eq(contacts.organizationId, orgId));
// Search with ILIKE (case-insensitive)
const searchResults = await db
.select()
.from(contacts)
.where(
and(
eq(contacts.organizationId, orgId),
like(contacts.name, `%${searchTerm}%`)
)
)
.limit(20);
Pour les clauses Where dynamiques (filtres de l'interface utilisateur), créez les conditions de manière conditionnelle :
import { SQL } from 'drizzle-orm';
async function searchContacts(orgId: string, filters: {
search?: string;
type?: 'individual' | 'company';
isActive?: boolean;
}) {
const conditions: SQL[] = [eq(contacts.organizationId, orgId)];
if (filters.search) {
conditions.push(like(contacts.name, `%${filters.search}%`));
}
if (filters.type) {
// Enum comparisons need explicit casting
conditions.push(eq(contacts.type, filters.type as 'individual' | 'company' | 'partner'));
}
if (filters.isActive !== undefined) {
conditions.push(eq(contacts.isActive, filters.isActive));
}
return db
.select()
.from(contacts)
.where(and(...conditions))
.limit(100);
}
Notez la conversion explicite des valeurs d'énumération : filters.type as 'individual' | 'company' | 'partner'. Le type enum de Drizzle n'accepte pas automatiquement les sous-types de chaînes plus étroites sans le cast.
Insérer, mettre à jour et supprimer
import { db } from '@ecosire/db';
import { contacts, NewContact } from '@ecosire/db/schema';
import { eq, and } from 'drizzle-orm';
// Insert with RETURNING
async function createContact(data: NewContact) {
const [created] = await db
.insert(contacts)
.values(data)
.returning();
return created; // Fully typed as Contact
}
// Upsert (insert or update on conflict)
async function upsertContact(data: NewContact) {
const [result] = await db
.insert(contacts)
.values(data)
.onConflictDoUpdate({
target: [contacts.organizationId, contacts.email],
set: {
name: data.name,
updatedAt: new Date(),
},
})
.returning();
return result;
}
// Update with RETURNING
async function updateContact(
id: string,
orgId: string,
updates: Partial<NewContact>
) {
const [updated] = await db
.update(contacts)
.set({ ...updates, updatedAt: new Date() })
.where(
and(
eq(contacts.id, id),
eq(contacts.organizationId, orgId) // Always filter by org
)
)
.returning();
return updated;
}
// Soft delete (recommended over hard delete)
async function deleteContact(id: string, orgId: string) {
return db
.update(contacts)
.set({ isActive: false, updatedAt: new Date() })
.where(
and(
eq(contacts.id, id),
eq(contacts.organizationId, orgId)
)
);
}
Utilisez toujours .returning() sur les instructions INSERT et UPDATE — cela évite un deuxième aller-retour SELECT pour obtenir l'enregistrement créé/mis à jour.
Transactions
Les opérations en plusieurs étapes qui doivent réussir ou échouer ensemble nécessitent des transactions :
async function createOrderWithItems(
orderData: NewOrder,
items: NewOrderItem[]
) {
return db.transaction(async (tx) => {
// Create the order
const [order] = await tx
.insert(orders)
.values(orderData)
.returning();
// Create order items
const createdItems = await tx
.insert(orderItems)
.values(items.map((item) => ({ ...item, orderId: order.id })))
.returning();
// Update inventory (must succeed or entire transaction rolls back)
for (const item of items) {
await tx
.update(products)
.set({
stock: sql`${products.stock} - ${item.quantity}`,
})
.where(eq(products.id, item.productId));
}
return { order, items: createdItems };
});
}
Lors de la transmission du contexte de transaction aux méthodes de service :
// Service accepts optional transaction for composability
async function activateLicense(
licenseId: string,
tx?: typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0]
) {
const queryRunner = tx || db;
return queryRunner
.update(licenses)
.set({ status: 'active', activatedAt: new Date() })
.where(eq(licenses.id, licenseId))
.returning();
}
Migrations avec kit bruine
La CLI drizzle-kit génère des migrations à partir des modifications de schéma :
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/schema/index.ts',
out: './src/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
Flux de travail :
# Generate migration SQL from schema changes
npx drizzle-kit generate
# Apply migrations to database
npx drizzle-kit migrate
# Push schema directly (dev only — skips migration files)
npx drizzle-kit push
# Open Drizzle Studio (visual database browser)
npx drizzle-kit studio
Pour la production, utilisez toujours migrate (et non push). La commande push est destinée aux itérations de développement local : elle ne crée pas de fichiers de migration, les modifications ne sont donc pas suivies.
Modèle de pagination
Une pagination efficace nécessite à la fois une requête de données et une requête de comptage :
interface PaginationParams {
page: number;
pageSize: number;
}
async function getContactsPaginated(orgId: string, { page, pageSize }: PaginationParams) {
const offset = (page - 1) * pageSize;
const [data, [{ total }]] = await Promise.all([
db
.select()
.from(contacts)
.where(eq(contacts.organizationId, orgId))
.limit(pageSize)
.offset(offset)
.orderBy(contacts.createdAt),
db
.select({ total: count() })
.from(contacts)
.where(eq(contacts.organizationId, orgId)),
]);
return {
data,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
L'exécution des deux requêtes en parallèle avec Promise.all réduit de moitié le temps de réponse par rapport à l'exécution séquentielle.
Pièges courants et solutions
Piège 1 : Utilisation de sql.raw() pour les requêtes dynamiques
// Dangerous — SQL injection vulnerability
const results = await db.execute(
sql.raw(`SELECT * FROM contacts WHERE name = '${userInput}'`)
);
// Safe — parameterized
const results = await db.execute(
sql`SELECT * FROM contacts WHERE name = ${userInput}`
);
Piège 2 : index manquants sur les colonnes fréquemment interrogées
Ajoutez toujours des index aux colonnes que vous filtrez. Les index manqués les plus courants :
(table) => ({
organizationIdx: index('contacts_org_idx').on(table.organizationId),
emailIdx: index('contacts_email_idx').on(table.email),
compositeIdx: index('contacts_org_active_idx').on(
table.organizationId,
table.isActive
),
})
Piège 3 : requêtes N+1 issues du chargement manuel de relations
// N+1 problem — one query per order
const orders = await db.select().from(ordersTable);
for (const order of orders) {
order.contact = await db
.select()
.from(contacts)
.where(eq(contacts.id, order.contactId));
}
// Solution — use Drizzle's relational query API
const orders = await db.query.orders.findMany({
with: { contact: true },
});
Piège 4 : Oublier d'exporter de nouvelles tables depuis schema/index.ts
Si vous créez un nouveau fichier de schéma mais oubliez de réexporter depuis schema/index.ts, drizzle-kit ne verra pas la nouvelle table et les migrations ne seront pas générées.
Questions fréquemment posées
Comment Drizzle se compare-t-il à Prisma pour les projets TypeScript ?
Drizzle est plus proche du SQL brut avec les types TypeScript : vous écrivez des requêtes qui mappent 1:1 au SQL. Prisma fait davantage de résumés, avec un langage de requête personnalisé et un fichier de schéma séparé. Drizzle offre une meilleure inférence TypeScript dans les requêtes complexes, une surcharge d'exécution plus réduite et ne nécessite pas d'étape de génération de code. Prisma dispose d'un écosystème plus vaste et de davantage de fonctionnalités intégrées telles que les suppressions logicielles et la journalisation d'audit. Pour les grandes équipes à l'aise avec SQL, Drizzle est généralement préféré.
Dois-je utiliser Drizzle-Kit Push ou migrer vers CI/CD ?
Utilisez toujours drizzle-kit migrate dans CI/CD. La commande push est destinée au développement local rapide : elle applique directement les modifications de schéma sans créer de fichiers de migration, il n'y a donc pas de piste d'audit et la restauration est difficile. Dans votre pipeline de déploiement, exécutez drizzle-kit migrate avant de démarrer l'application pour vous assurer que le schéma est synchronisé.
Comment gérer l'amorçage de la base de données avec Drizzle ?
Créez un fichier de départ séparé et importez directement votre schéma. Utilisez onConflictDoNothing() pour les graines idempotentes qui n'échoueront pas lors d'une nouvelle exécution. Stockez les données de départ dans des constantes pour des mises à jour faciles et exécutez des graines après les migrations dans votre script de configuration de développement.
Comment effectuer une recherche en texte intégral avec Drizzle et PostgreSQL ?
Utilisez le littéral de modèle sql avec les fonctions to_tsvector et to_tsquery de PostgreSQL. Drizzle n'abstrait pas la recherche en texte intégral, mais vous pouvez l'écrire directement. Ajoutez un index GIN sur la colonne tsvector pour plus de performances et utilisez websearch_to_tsquery pour les termes de recherche fournis par l'utilisateur afin d'analyser en toute sécurité les requêtes en langage naturel.
Comment gérer les modifications de schéma sans temps d'arrêt ?
Utilisez des migrations additives : ajoutez d'abord des colonnes comme nullables, remplissez les données, puis ajoutez la contrainte NOT NULL. Ne supprimez jamais de colonnes dans la même migration qui les supprime du code. La séquence est la suivante : 1) Déployer le code qui ignore l'ancienne colonne, 2) Déployer la migration pour la supprimer. Cela garantit que la restauration est toujours possible sans perte de données.
Prochaines étapes
Une couche de base de données bien conçue constitue la base sur laquelle repose chaque application de production. L'équipe d'ingénierie d'ECOSIRE travaille quotidiennement avec Drizzle ORM et PostgreSQL, gérant plus de 65 fichiers de schéma sur un système multi-tenant complexe.
Que vous ayez besoin de conseils en architecture de base de données, d'intégration Odoo ERP ou d'un système backend complet construit avec des outils TypeScript modernes, explorez nos services pour savoir comment nous pouvons vous aider.
Rédigé par
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
Articles connexes
Migrations de bases de données sans temps d'arrêt avec Drizzle ORM
Exécutez des migrations de bases de données sans temps d'arrêt à l'aide de Drizzle ORM. Couvre le modèle de contrat étendu, les modifications de schéma rétrocompatibles, les stratégies de restauration et l'intégration CI/CD pour PostgreSQL.
Odoo Performance Tuning : PostgreSQL et optimisation du serveur
Guide expert sur le réglage des performances d’Odoo 19. Couvre la configuration PostgreSQL, l'indexation, l'optimisation des requêtes, la mise en cache Nginx et le dimensionnement du serveur pour les déploiements d'entreprise.
Requêtes de base de données en langage naturel avec OpenClaw
Comment OpenClaw permet des requêtes de base de données en langage naturel, traduisant des questions commerciales en anglais simple en SQL précis sans exposer les informations d'identification de la base de données ni la complexité des requêtes.