Was ist Drizzle?
Drizzle ist ein TypeScript ORM, das SQL als First-Class Citizen behandelt. Ihr Schema besteht aus gewöhnlichem TypeScript, Ihre Queries werden über Methoden aufgebaut, die SQL-Klauseln widerspiegeln, und die Runtime ist schlank genug, um auf der Edge zu laufen. Es gibt keinen Code-Generation-Schritt und keine Binary Engine: Sie importieren drizzle-orm und führen Queries aus.
Dieses Design spiegelt sich in jeder API wider. db.select().from(users).where(eq(users.id, id)) wird in derselben Reihenfolge gelesen wie das SQL, das daraus resultiert. Die Library versteckt die Datenbank nicht, sondern versieht sie mit Typen. Wenn Sie Joins, Indexes und ON CONFLICT bereits verstehen, verstehen Sie bereits den Großteil von Drizzle.
Drizzle unterstützt PostgreSQL, MySQL und SQLite sowie Serverless- und Edge-Driver wie Neon, PlanetScale, Turso, Cloudflare D1 und das integrierte SQLite von Bun. Das gleiche Schema und der gleiche Query-Stil gelten für alle diese Datenbanken, weshalb Drizzle zu einer beliebten Wahl für Edge- und Serverless-Datenbanken geworden ist.
Dein Schema ist TypeScript
Es gibt kein schema.prisma und keinen generierten Client. Du definierst Tabellen mit einem Builder pro Dialekt und exportierst diese.
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(),
});
Das erste Argument ist der SQL-Tabellenname, und jeder Column-Builder nimmt den Spaltennamen entgegen. Dieses explizite Mapping ermöglicht es der Datenbank, snake_case zu verwenden, während dein Code camelCase nutzt. Column-Methoden wie .notNull(), .unique() und .default() fügen dieselben Constraints hinzu, die du sonst manuell schreiben würdest.
Relationen werden separat mit relations() deklariert. Sie erstellen keine Spalten, sondern teilen der relationalen Query-API mit, wie die Tabellen miteinander verknüpft sind.
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],
}),
}));
Typen gibt es gratis
Drizzle leitet die Zeilentypen aus dem Schema ab, sodass man sie selten manuell schreiben muss. Jede Tabelle stellt $inferSelect und $inferInsert bereit:
import { users } from "./schema";
type User = typeof users.$inferSelect;
type NewUser = typeof users.$inferInsert;
function displayName(user: User) {
return user.name ?? user.email;
}
Die Helper InferSelectModel und InferInsertModel bewirken dasselbe und lassen sich in einigen Codebases besser lesen:
import type { InferInsertModel, InferSelectModel } from "drizzle-orm";
type User = InferSelectModel<typeof users>;
type NewUser = InferInsertModel<typeof users>;
Da diese Typen aus demselben Objekt stammen, das die Query aufbaut, aktualisiert eine Umbenennung einer Spalte sofort alle Konsumenten. Das ist der Vorteil davon, das Schema in TypeScript statt in einer separaten DSL zu führen: Es gibt keine generierte Datei, die asynchron werden oder aus dem Sync geraten kann.
Constraints und Indizes direkt bei den Spalten
Regeln auf Spaltenebene decken NOT NULL, UNIQUE und Standardwerte ab. Regeln auf Tabellenebene, wie zusammengesetzte Schlüssel, Indizes und Checks, kommen in das optionale dritte Argument, welches ein Array zurückgibt.
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')`),
],
);
Indizes im Schema statt in einem manuell ausgeführten Skript zu definieren, bedeutet, dass drizzle-kit generate diese zusammen mit der Tabelle erstellt und jede Umgebung sie in der gleichen Reihenfolge erhält. Wenn eine Abfrage langsam ist, gehört der benötigte Index normalerweise hier direkt neben die Spalte, die er abdeckt.
Verbindung zur Datenbank herstellen
Erstellen Sie eine Driver-Verbindung, umschließen Sie diese mit drizzle und übergeben Sie das Schema, damit die relationale API Ihre Relationen erkennen kann.
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 });
Ersetzen Sie node-postgres durch den Driver, den Sie tatsächlich einsetzen: drizzle-orm/neon-http für den HTTP-Endpunkt von Neon, drizzle-orm/postgres-js, drizzle-orm/better-sqlite3, drizzle-orm/d1 oder drizzle-orm/bun-sqlite. Die darüber liegende Query-API db bleibt identisch, was genau das Ziel ist.
Serverless- und Edge-Driver
Der Grund, warum Drizzle so gut mit Edge-Runtimes funktioniert, ist, dass außer dem Driver nichts gebündelt werden muss. Neon über HTTP und Cloudflare D1 sind die beiden Driver, denen man am häufigsten begegnet.
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);
},
};
Erstellen Sie den Client einmal pro Isolate anstatt pro Request und übergeben Sie { schema }, damit db.query weiterhin funktioniert. HTTP- und D1-Driver können Prepared Statements oder Transaktionen je nach Protokoll einschränken. Prüfen Sie daher die Driver-Notizen, bevor Sie sich in der Produktion auf eines von beiden verlassen.
Der Query Builder liest sich wie SQL
Queries werden durch das Verketten von Methoden zusammengesetzt, die SQL-Klauseln entsprechen. select, from, where, orderBy, limit und offset stehen eins zu eins für das Statement, das sie generieren.
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);
Operatoren sind importierte Funktionen statt Symbole: eq, ne, gt, gte, lt, lte, inArray, notInArray, like, ilike, isNull, isNotNull sowie die Kombinatoren and, or und not. Wenn Sie etwas benötigen, das der Builder nicht anbietet, ermöglicht sql den Wechsel zu raw SQL, während die Parameterisierung beibehalten wird.
import { sql } from "drizzle-orm";
const active = await db
.select()
.from(users)
.where(sql`${users.createdAt} > now() - interval '30 days'`);
Filtern, Sortieren und Pagination
Bedingungen werden mit and, or und not kombiniert, und die Operator-Funktionen decken die üblichen Prädikate ab. Das Zusammenspiel liest sich fast wie eine WHERE-Klausel.
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);
Das ist Keyset-Pagination: Anstatt offset zu verwenden, was die Datenbank dazu zwingt, Zeilen zu scannen und zu verwerfen, paginieren Sie ab der letzten ID, die Sie gesehen haben. Für kleinere Ergebnismengen sind limit und offset völlig ausreichend, und für nullable oder set-basierte Filter gilt derselbe Builder:
const results = await db
.select()
.from(users)
.where(
or(
isNull(users.name),
inArray(users.email, ["[email protected]", "[email protected]"]),
),
);
Für das Sortieren werden asc und desc auf Spalten angewendet oder sql für Ausdrücke wie desc(sqlcount(${posts.id})). Da jede Klausel explizit ist, gibt es keine versteckte Sortierung oder Standardfilter, die Sie überraschen könnten.
Joins und Aggregate
Joins sind explizit, genau wie in SQL. Sie wählen innerJoin, leftJoin oder rightJoin und geben die Bedingung an.
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);
Da Sie den groupBy selbst schreiben, gibt es keinen versteckten Query-Plan. Wenn das SQL falsch wäre, wäre auch das TypeScript falsch. Subqueries, CTEs mit with, Window-Funktionen und Set-Operationen sind alle verfügbar, entweder über dedizierte Helper oder über sql-Fragmente.
Die Relational Query API
Der Query Builder ist präzise, aber für den häufigen Anwendungsfall – das Laden eines Benutzers zusammen mit seinen Posts – sehr ausführlich. Die Relational Query API, db.query, liest sich eher wie 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 und findFirst erkennen das relations(), das Sie definiert haben, und with lädt diese in einem einzigen Roundtrip pro Relation. Die beiden APIs nutzen dasselbe Schema und können innerhalb einer Codebasis gemischt werden: Verwenden Sie den Query Builder, wenn Sie volle Kontrolle benötigen, und db.query, wenn Sie ein verschachteltes Ergebnis wünschen, ohne den Join manuell schreiben zu müssen.
Daten schreiben
Inserts, Updates und Deletes funktionieren genauso direkt. Füge .returning() hinzu, um die betroffenen Zeilen in einem einzigen Statement zurückzuerhalten, wodurch ein nachfolgendes SELECT vermieden wird.
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 und onConflictDoUpdate werden bei Upserts auf ON CONFLICT abgebildet. returning() wird von PostgreSQL, SQLite und MariaDB unterstützt; MySQL unterstützt dies nicht, weshalb dort ein erneuter Select nötig ist. Die API spiegelt bewusst den Dialekt der verbundenen Datenbank wider, anstatt vorzugeben, dass alle Datenbanken identisch seien.
Transaktionen
Transaktionen sind Callbacks, die einen Transaction-Handle erhalten. Jede Abfrage innerhalb des Callbacks muss diesen Handle verwenden.
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));
});
Ein Throw innerhalb des Callbacks führt zu einem Rollback der Transaktion. Verschachtelte tx.transaction-Aufrufe werden zu Savepoints, sofern der Dialekt diese unterstützt. Über das Options-Objekt kannst du zudem das Isolation Level festlegen. Wie bei jeder Datenbank sollte der Callback kurz gehalten werden; vermeide es, darin nicht zusammenhängende Netzwerkaufrufe mit await zu erwarten.
Migrationen mit drizzle-kit
drizzle-kit ist die dazugehörige CLI. Sie liest drizzle.config.ts sowie das Schema aus und verwaltet die Migrationen.
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 vergleicht das Schema mit dem letzten Snapshot, schreibt eine nummerierte SQL-Datei in out und zeichnet einen Snapshot im Journal auf. migrate wendet ausstehende Dateien in der richtigen Reihenfolge an. Beides ist für die Produktion sicher, da das SQL ein überprüfbares Artefakt ist, das ihr committet.
push ist die Abkürzung für das Prototyping: Damit wird die Datenbank an das Schema angepasst, ohne Migrationsdateien zu schreiben. Das ist in der Entwicklung praktisch, aber in der Produktion aus denselben Gründen riskant wie jeder andere schema-push Befehl. pull funktioniert in die andere Richtung und führt eine Introspektion einer bestehenden Datenbank in TypeScript durch, während studio einen lokalen Daten-Browser öffnet. Bei HTTP-only Treibern wie Neon verwendet bitte den Migrator des Treibers anstelle der CLI:
import { migrate } from "drizzle-orm/neon-http/migrator";
await migrate(db, { migrationsFolder: "./drizzle" });
Prepared Statements
Drizzle kann eine Query einmal vorbereiten und sie mehrfach mit unterschiedlichen Parametern ausführen, was den Planning-Overhead in Performance-kritischen Bereichen (hot paths) reduziert.
const getUser = db
.select()
.from(users)
.where(eq(users.id, sql.placeholder("id")))
.prepare("get_user");
const user = await getUser.execute({ id: userId });
Platzhalter werden mit sql.placeholder("name") deklariert und bei der Ausführung übergeben. Prepared Statements werden benannt, damit der Driver den Plan cachen kann; beachte jedoch, dass Connection Pooler im Transaction-Mode, wie etwa PgBouncer, diese möglicherweise nicht über verschiedene Verbindungen hinweg beibehalten.
Ein ORM, viele Datenbanken
Da der Query Builder SQL widerspiegelt, passt sich Drizzle an jeden Dialekt an, anstatt diese zu vereinheitlichen. Sie importieren Column Builder aus drizzle-orm/pg-core, drizzle-orm/mysql-core oder drizzle-orm/sqlite-core, und die verfügbaren Typen und Funktionen richten sich nach der jeweiligen Datenbank.
Das bedeutet, dass PostgreSQL-spezifische Features wie jsonb, Arrays, pgEnum und Generated Columns erstklassig unterstützt werden, während MySQL und SQLite ihre eigenen Äquivalente erhalten. Der Preis dafür ist, dass die Migration eines Schemas zwischen Dialekten mit Aufwand verbunden ist: Die Abstraktionen sind zwar identisch, das SQL jedoch nicht. Dies ist ein bewusster Kompromiss für Teams, die die tatsächlichen Funktionen der Datenbank nutzen möchten, anstatt sich auf den kleinsten gemeinsamen Nenner zu beschränken.
Warum es sich wie SQL anfühlt
Die meisten ORMs verstecken die Datenbank und stellen stattdessen Objekte bereit. Drizzle macht genau das Gegenteil: Es liefert dir Typen für die Datenbank, die du bereits kennst. Daraus ergeben sich drei Konsequenzen.
Erstens ist das Debugging einfacher. Die Abfrage, die du geschrieben hast, ist genau die Abfrage, die ausgeführt wird. Das Lesen eines Slow-Logs oder einer EXPLAIN-Ausgabe lässt sich also direkt auf den Code zurückführen. Zweitens ist das Wissen übertragbar, da Kenntnisse über Joins, Indizes und ON CONFLICT unverändert anwendbar sind. Drittens gibt es weniger „Magie“, die dich überraschen könnte: Keine Lazy-Loading-Proxies, die Abfragen auslösen, sobald du eine Eigenschaft ansprichst, und keine Identity Map, die entscheidet, was sich bereits im Speicher befindet.
Der Preis dafür ist, dass du SQL beherrschen solltest. Drizzle generiert gerne eine ineffiziente Abfrage, wenn du danach fragst, und es wird dich nicht vor einem fehlenden Index bewahren.
Performance und Bundle-Größe
Drizzle wird ohne Rust-Engine und ohne generierten Client ausgeliefert. Die Runtime besteht aus einfachem TypeScript, das Tree-Shaking unterstützt. Somit enthält eine Serverless Function oder ein Edge Worker nur den Query Builder und den Driver. Es gibt keine separate Binary, die gebündelt werden muss oder Cold-Starts verursacht, was ein wesentlicher Grund ist, warum Edge-Plattformen Drizzle bevorzugen.
Auf dem Server verhält es sich wie bei jedem ORM: Die Datenbank erledigt die Arbeit, und die Struktur Ihrer Queries entscheidet über die Performance. Nutzen Sie .returning(), um zusätzliche Round-Trips zu vermeiden, bereiten Sie häufig genutzte Queries vor, wählen Sie nur die Spalten aus, die Sie tatsächlich benötigen, und lassen Sie Indexe ihre Arbeit machen. Da im Hintergrund nichts ohne Ihr Wissen gecacht oder gebatcht wird, entspricht die gemessene Performance exakt der Performance des von Ihnen geschriebenen SQL.
Best Practices
- Behandeln Sie
schema.tsals die einzige „Source of Truth“ und exportieren Sie Tabellen und Relationen von einem zentralen Ort aus. - Nutzen Sie
drizzle-kit generateund committen Sie das SQL; lassen Siepushniemals die Production-Umgebung berühren. - Wählen Sie nur die Spalten aus, die Sie tatsächlich benötigen, und fügen Sie
.returning()hinzu, anstatt einen weiteren Read-Vorgang durchzuführen. - Bevorzugen Sie
db.querymitwithfür verschachtelte Reads und den Query Builder für alle benutzerdefinierten Abfragen. - Kapseln Sie mehrstufige Schreibvorgänge in
db.transactionein und verwenden Sie konsistent dentx-Handle. - Nutzen Sie
sql-Platzhalter und Prepared Statements in performance-kritischen Bereichen („hot paths“). - Passen Sie den Driver an die Plattform an und teilen Sie sich eine einzige
db-Instanz pro Prozess. - Fügen Sie Indexe im Schema direkt neben den Spalten hinzu, die sie abdecken.
- Führen Sie Migrationen in der CI/CD-Pipeline aus, bevor der neue Code deployed wird.
Häufige Fehler
drizzle-kit pushgegen eine Produktionsdatenbank auszuführen und dabei Spalten zu löschen.- Das äußere
dbinnerhalb einer Transaktion zu verwenden, anstatt des bereitgestelltentx. - Zu vergessen,
{ schema }andrizzlezu übergeben, wasdb.querybeeinträchtigt. where-Bedingungen mit dem falschen Operator zu schreiben, zum Beispieleq, woinArraybenötigt wird.- In Performance-kritischen Bereichen (Hot Paths) standardmäßig alle Spalten auszuwählen.
- Indizes bei Fremdschlüsseln zu vergessen, die in Joins verwendet werden.
- Davon auszugehen, dass MySQL
.returning()unterstützt. sql-Fragmente als automatisch sicher zu betrachten, wenn sie User-Strings interpolieren.- Zu vergessen,
generatenach der Bearbeitung des Schemas auszuführen, und sich dann zu fragen, warum die Migration leer ist.
Wie geht es weiter?
Drizzle belohnt fundierte Kenntnisse über die zugrunde liegende Datenbank. Lesen Sie daher den PostgreSQL-Guide zu Indizes, Transaktionen und Query Planning. Vergleichen Sie Drizzle mit Prisma, das einen Client aus einem Schema generiert, und TypeORM, das Entities als Klassen modelliert. Falls Sie sich noch in die Sprache einarbeiten, ist der TypeScript-Guide das Fundament, auf dem alle drei aufbauen.