Was ist Prisma?
Prisma ist ein schema-first ORM für TypeScript und Node.js. Du beschreibst deine Daten einmal in schema.prisma, führst einen Generator aus und erhältst einen Client, dessen Methoden und Rückgabetypen direkt aus diesem Schema abgeleitet werden. Es gibt keine Decorators, keine Repository-Klassen und in den gängigen Anwendungsfällen keine handgeschriebenen SQL-Strings.
Das Konzept dahinter ist, dass das Datenbankschema zu einer einzigen deklarativen Datei wird, die von Menschen gelesen und von Tools verarbeitet wird. Daraus generiert Prisma einen typisierten Client, den du in deinen Code importierst, SQL-Migrationen, die du prüfen und committen kannst, sowie eine Studio-UI zum Durchsuchen der Daten. Prisma unterstützt PostgreSQL, MySQL, MariaDB, SQLite, SQL Server, CockroachDB und MongoDB. Der generierte Client übersetzt jeden Aufruf in SQL oder das MongoDB-Wire-Protokoll, bevor die Zeilen wieder in einfache Objekte gemappt werden.
Wenn du bereits ein ORM im ActiveRecord-Stil verwendet hast, besteht der konzeptionelle Unterschied darin, dass Prisma schema-first und client-generated ist, anstatt class-first und runtime-reflective. Diese eine Entscheidung erklärt die meisten seiner Stärken sowie die meisten seiner Nachteile.
Das Schema ist die Source of Truth
Alles beginnt in schema.prisma. Es enthält drei Arten von Blöcken: einen datasource (wo die Datenbank liegt), einen generator (was ausgegeben werden soll) und einen model pro Tabelle.
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(uuid())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
}
Einige Konventionen sollte man verinnerlichen. Ein Feld, das auf ? endet, ist nullable, während ein List-Typ wie Post[] eine Relation und keine Spalte darstellt. Attribute beginnen mit @ für Felder und @@ für Blöcke. @id markiert den Primary Key, @unique erstellt einen Unique Index, @default(...) liefert einen Standardwert, und @map sowie @@map benennen die zugrunde liegende Spalte oder Tabelle um, falls diese nicht mit deinem Modell übereinstimmt.
Das obige Modell wird auf eine User-Tabelle mit den Spalten id, email, name und createdAt abgebildet, plus eine virtuelle posts-Relation, die Prisma über einen Join oder eine zweite Abfrage auflöst.
Den Client generieren und verwenden
Der Client von Prisma besteht aus generiertem Code. Führen Sie nach der Bearbeitung des Schemas folgenden Befehl aus:
pnpm prisma generate
Dies liest schema.prisma aus und schreibt einen Client in node_modules/.prisma/client oder in einen von Ihnen gewählten Ordner mit dem neueren prisma-client-Generator. Anschließend instanziieren Sie diesen einmal und verwenden ihn wiederholt:
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const user = await prisma.user.findUnique({
where: { email: "[email protected]" },
});
Da der Client generiert wird, sind sowohl prisma.user als auch die Struktur von user dem Type Checker bekannt. Wenn Sie ein Feld im Schema umbenennen und den Client neu generieren, schlägt die Kompilierung bei jeder veralteten Verwendung fehl. Dieser Feedback-Loop ist der Hauptgrund, warum Teams Prisma einsetzen.
Vermeiden Sie in Serverless- oder Hot-Reloading-Umgebungen die Erstellung eines neuen Clients pro Request oder pro Reload. Hängen Sie stattdessen einen Client an das globale Objekt an:
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
Daten lesen
Prisma stellt für jede Lese-Struktur eine eigene Methode bereit. findUnique ruft eine einzelne Zeile über ein eindeutiges Feld ab, findFirst ruft eine Zeile ab, die einem beliebigen Filter entspricht, und findMany gibt eine Liste zurück.
const post = await prisma.post.findUnique({ where: { id: 42 } });
const latest = await prisma.post.findFirst({
where: { published: true },
orderBy: { createdAt: "desc" },
});
const posts = await prisma.post.findMany({
where: {
published: true,
title: { contains: "prisma", mode: "insensitive" },
authorId: { in: [userId] },
},
orderBy: { createdAt: "desc" },
take: 20,
skip: 0,
});
Filter-Operatoren sind benannt statt symbolisch: equals, not, in, notIn, lt, lte, gt, gte, contains, startsWith, endsWith und mode. Kombinieren Sie diese mit AND, OR und NOT-Arrays:
const posts = await prisma.post.findMany({
where: {
OR: [
{ title: { contains: "orm" } },
{ author: { email: { endsWith: "@example.com" } } },
],
NOT: { published: false },
},
});
take und skip implementieren Offset-Pagination. Bei großen Ergebnismengen ist die Cursor-Pagination vorzuziehen, da diese ab der letzten Zeile paginiert, anstatt vom Anfang an zu zählen:
const page = await prisma.post.findMany({
take: 20,
skip: 1,
cursor: { id: lastSeenId },
orderBy: { id: "asc" },
});
findUnique akzeptiert kein nicht-eindeutiges where; verwenden Sie dafür findFirst. Die Varianten findUniqueOrThrow und findFirstOrThrow werfen einen Fehler, anstatt null zurückzugeben, was einen Conditional-Branch überflüssig macht, wenn die Zeile zwingend existieren muss.
Daten schreiben
Erstellen, Aktualisieren und Löschen sind gleichermaßen typisiert. create, update, upsert und delete operieren auf einer einzelnen Zeile, während createMany, updateMany und deleteMany auf Datensätzen operieren.
const user = await prisma.user.create({
data: { email: "[email protected]", name: "Ada" },
});
await prisma.user.update({
where: { id: user.id },
data: { name: "Ada Lovelace" },
});
await prisma.user.upsert({
where: { email: "[email protected]" },
update: { name: "Ada Lovelace" },
create: { email: "[email protected]", name: "Ada" },
});
await prisma.user.delete({ where: { id: user.id } });
Für Bulk-Inserts führt createMany ein einzelnes INSERT aus und ist dramatisch schneller als eine Schleife über create:
await prisma.post.createMany({
data: [
{ title: "Hello", authorId: user.id },
{ title: "World", authorId: user.id },
],
skipDuplicates: true,
});
Der Kompromiss besteht darin, dass createMany keine verschachtelten Relationen schreiben kann; es ist nur für flache Zeilen gedacht. updateMany und deleteMany akzeptieren dieselben Filter wie findMany, sodass ein fehlendes where tatsächlich jede Zeile betrifft.
Relations, include und select
Relations werden auf beiden Seiten definiert. User.posts ist eine Liste und Post.author ist ein Einzelwert, die auf der besitzenden Seite durch @relation(fields: [authorId], references: [id]) miteinander verknüpft sind.
Standardmäßig gibt Prisma nur skalare Spalten zurück. Um eine Relation zu laden, fügst du include hinzu:
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
posts: {
where: { published: true },
orderBy: { createdAt: "desc" },
take: 10,
},
},
});
select ist das präzisere Werkzeug: Damit legst du exakt fest, welche Felder zurückgegeben werden sollen – sowohl für das Modell als auch für verschachtelte Relations.
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
posts: {
select: { title: true },
where: { published: true },
},
_count: { select: { posts: true } },
},
});
Du kannst select und include nicht auf derselben Ebene kombinieren, da select bereits die Frage beantwortet, was zurückgegeben werden soll. Nutze select, wenn ein Endpoint eine feste Antwortstruktur hat, und include, wenn du tatsächlich den gesamten verknüpften Datensatz benötigst. Ganze Zeilen zurückzugeben und diese erst in JavaScript zu filtern, verschwendet Bandbreite und Speicher; select verhindert hier eine häufige Performance-Verschlechterung.
Verschachtelte Schreibvorgänge (Nested Writes)
Eines der nützlichsten Features von Prisma ist die Möglichkeit, einen Parent und dessen Children in einem einzigen Aufruf zu schreiben. Die verschachtelten Operationen create, connect, update und delete werden alle innerhalb einer impliziten Transaction ausgeführt.
const user = await prisma.user.create({
data: {
email: "[email protected]",
posts: {
create: [
{ title: "First post" },
{ title: "Second post", published: true },
],
},
},
include: { posts: true },
});
Um bestehende Zeilen zu verbinden, anstatt sie neu zu erstellen, wird connect verwendet; zum Trennen einer Relation nutzt man disconnect:
await prisma.post.update({
where: { id: postId },
data: {
author: { connect: { email: "[email protected]" } },
},
});
Verschachtelte Schreibvorgänge halten verwandte Daten konsistent, ohne dass Sie manuell eine Transaction öffnen müssen – genau die Art von Bookkeeping, die ein ORM übernehmen sollte.
Migrationen in der Entwicklung und Produktion
Prisma Migrate wandelt die Unterschiede zwischen deinem Schema und deiner Datenbank in versionierte SQL-Dateien unter prisma/migrations um.
pnpm prisma migrate dev --name add_posts
pnpm prisma migrate deploy
pnpm prisma migrate status
migrate dev ist ein Befehl für die Entwicklung. Er vergleicht das Schema mit der Datenbank, schreibt eine neue Migration, wendet diese an und regeneriert den Client. Zudem wird eine Shadow Database verwendet, um Drift zu erkennen; daher benötigt die Entwickler-Rolle die Berechtigung, Datenbanken zu erstellen und zu löschen. Wenn Drift festgestellt wird, kann ein Reset der Datenbank angeboten werden, wodurch Daten gelöscht werden.
migrate deploy ist der Befehl für die Produktion. Er wendet ausstehende Migrationen an und sonst nichts: keine Generierung, keine Shadow Database, keine Resets. Führe diesen Befehl in deiner Deploy-Pipeline aus, bevor der neue Code Traffic empfängt.
Für schnell zusammengestellte Prototypen überspringt prisma db push die Migrationshistorie komplett und erzwingt, dass die Datenbank dem Schema entspricht:
pnpm prisma db push
db push ist schnell und bequem, hinterlässt jedoch keinen Audit-Trail und kann Spalten oder Tabellen löschen, um das Schema anzupassen. Richte diesen Befehl niemals auf die Produktion. Weitere nützliche Befehle sind prisma migrate reset, um eine Entwicklungsdatenbank zu löschen, neu zu erstellen und neu zu seeden, sowie prisma migrate diff, um anzuzeigen, was geändert würde, ohne die Änderungen tatsächlich anzuwenden.
Seeding
Seed-Daten gehören in prisma/seed.ts. Registriere sie in package.json, damit Prisma weiß, wie sie auszuführen sind:
{
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
await prisma.user.upsert({
where: { email: "[email protected]" },
update: {},
create: {
email: "[email protected]",
name: "Ada",
posts: { create: [{ title: "Welcome" }] },
},
});
}
main()
.then(() => prisma.$disconnect())
.catch(async (error) => {
console.error(error);
await prisma.$disconnect();
process.exit(1);
});
Führe sie mit pnpm prisma db seed aus. Da das Seeding nach migrate dev und migrate reset erfolgt, ist eine frisch erstellte Datenbank niemals leer, was das Onboarding und die Tests vorhersehbar macht.
Transaktionen
Prisma bietet zwei Transaction-APIs. Die sequentielle Form nimmt ein Array von Operationen entgegen und führt diese nacheinander aus:
const [debit, credit] = await prisma.$transaction([
prisma.account.update({
where: { id: 1 },
data: { balance: { decrement: 5000 } },
}),
prisma.account.update({
where: { id: 2 },
data: { balance: { increment: 5000 } },
}),
]);
Die interaktive Form stellt einen Transaction-Client bereit und ermöglicht es, basierend auf den Ergebnissen zu verzweigen:
await prisma.$transaction(async (tx) => {
const sender = await tx.account.findUniqueOrThrow({ where: { id: 1 } });
if (sender.balance < 5000) throw new Error("insufficient_funds");
await tx.account.update({
where: { id: 1 },
data: { balance: { decrement: 5000 } },
});
await tx.account.update({
where: { id: 2 },
data: { balance: { increment: 5000 } },
});
});
Verwenden Sie tx für jede Abfrage innerhalb einer interaktiven Transaktion; die Verwendung des äußeren prisma-Clients würde die Transaktion verlassen. Sie können das Verhalten mit maxWait anpassen, welches steuert, wie lange auf eine Verbindung gewartet wird, und mit timeout, welches die maximale Laufzeit der Transaktion begrenzt. Das Isolation Level lässt sich über isolationLevel festlegen. Halten Sie Transaktionen kurz und lassen Sie diese niemals über einen HTTP-Aufruf oder eine Benutzerinteraktion hinweg offen.
Raw SQL, wenn es nötig ist
Der Query Builder deckt die meisten Lesezugriffe ab, aber für Reporting-Abfragen und datenbankspezifische Features ist manchmal SQL erforderlich. $queryRaw ist ein Tagged Template, das Werte automatisch für Sie parametrisiert:
import { Prisma } from "@prisma/client";
const rows = await prisma.$queryRaw<
{ day: Date; revenue: bigint }[]
>(Prisma.sql`
SELECT date_trunc('day', created_at) AS day,
sum(total_cents) AS revenue
FROM orders
WHERE status = 'paid'
GROUP BY 1
ORDER BY 1 DESC
`);
Verwenden Sie $queryRaw für SELECT und $executeRaw für Schreibvorgänge. Beide akzeptieren Tagged Templates, welche SQL-Injection verhindern, da interpolierte Werte zu Bound-Parametern werden. $queryRawUnsafe und $executeRawUnsafe existieren für wirklich dynamisches SQL und sollten als gefährlich eingestuft werden: Interpolieren Sie niemals Benutzereingaben in diese Funktionen. Prisma.sql und Prisma.join ermöglichen es Ihnen, Fragmente zu kombinieren und dabei die Parametrisierung beizubehalten.
Pooling, Serverless und Accelerate
Jede PrismaClient-Instanz besitzt einen eigenen Connection Pool. Auf einem Long-lived-Server ist genau das gewünscht. Auf einer Serverless-Plattform erstellt jedoch jede Funktionsinstanz ihren eigenen Client und damit ihren eigenen Pool. Ein Traffic-Spike kann so die max_connections der Datenbank innerhalb von Sekunden erschöpfen.
Der erste Hebel ist der Connection String. Begrenzen Sie den Pool und die Wartezeit:
DATABASE_URL="postgresql://user:pass@host:5432/shop?connection_limit=5&pool_timeout=10"
In Funktionen, in denen jede Instanz jeweils nur einen Request bearbeitet, ist connection_limit=1 oft die richtige Wahl. Wenn Sie PgBouncer verwenden, fügen Sie ?pgbouncer=true hinzu, damit Prisma auf Prepared Statements verzichtet, da diese von Transaction Pooling nicht beibehalten werden können. Falls Sie die Connection Limits der Datenbank nicht ändern können, fungiert Prisma Accelerate als managed Pooler und Cache vor der Datenbank; Sie erweitern den Client mit der entsprechenden Extension und routen die Queries über einen globalen Endpoint. Der ältere Data Proxy löste dasselbe Problem und wurde durch Accelerate ersetzt.
Unabhängig von Ihrer Wahl gilt die gleiche Regel: Verwenden Sie pro Prozess nur einen Client und achten Sie darauf, dass die Anzahl der Applikationsinstanzen nicht zu mehr Verbindungen führt, als die Datenbank verarbeiten kann.
Die N+1-Frage
Ein N+1-Problem tritt auf, wenn Sie eine Liste abrufen und anschließend für jede Zeile eine eigene Abfrage ausführen, um eine Relation zu laden. Prisma vermeidet die klassische Form dieses Problems, da include und select Relationen als Teil desselben Aufrufs laden: Für ein findMany mit einer Relation wird eine Abfrage für die Eltern und eine für die Kinder ausgeführt, nicht eine pro Elternelement.
Sie führen das Problem selbst wieder ein, wenn Sie eine Schleife verwenden:
const users = await prisma.user.findMany();
for (const user of users) {
// One query per user: this is the N+1.
const posts = await prisma.post.findMany({ where: { authorId: user.id } });
}
Laden Sie die Relation stattdessen direkt in der ursprünglichen Abfrage:
const users = await prisma.user.findMany({
include: { posts: true },
});
Bei tief verschachtelten Lesezugriffen führt die Standardstrategie von Prisma eine Abfrage pro Relationsebene aus. In Fällen, in denen eine einzelne Joined-Query spürbar schneller ist, weist die Option relationLoadStrategy: "join" Prisma an, stattdessen einen LEFT JOIN zu verwenden. Messen Sie die Performance, bevor Sie wechseln, da die beiden Strategien einen Kompromiss zwischen der Anzahl der Round-Trips und duplizierten Elternspalten darstellen.
Best Practices
- Betrachten Sie
schema.prismaals die “Source of Truth” und ändern Sie diese ausschließlich über Migrationen, niemals manuell. - Instanziieren Sie
PrismaClienteinmal pro Prozess und verwenden Sie diese Instanz wieder; schützen Sie den Hot Reload mit einem globalen Objekt. - Verwenden Sie
selectfür feste Response-Strukturen undincludenur dann, wenn Sie vollständige Datensätze benötigen. - Bevorzugen Sie Cursor-Pagination gegenüber großen
skip-Offsets. - Nutzen Sie
createManyfür Bulk-Inserts und Nested Writes für verwandte Zeilen. - Halten Sie interaktive Transaktionen kurz und verwenden Sie innerhalb dieser immer den
tx-Client. - Greifen Sie auf
$queryRawmit Tagged Templates zurück – niemals auf die Unsafe-Varianten –, wenn Sie SQL benötigen. - Setzen Sie
connection_limitund nutzen Sie in Serverless-Umgebungen einen Pooler wie Accelerate oder PgBouncer. - Committen Sie die Migrations-Ordner und führen Sie
migrate deployin der CI/CD aus, anstattdb push.
Häufige Fehler
- Ausführen von
prisma db pushgegen die Production-Umgebung und dabei Spalten verlieren. - Erstellen eines neuen
PrismaClientpro Request, was zur Erschöpfung der Verbindungen führt. - Vergessen von
prisma generatenach einer Schema-Änderung und sich anschließend wundern, warum die Typen veraltet sind. - Abrufen vollständiger Zeilen mit
includeund anschließendes Filtern der Felder in JavaScript. - Verwendung des äußeren Clients innerhalb einer interaktiven Transaction, wodurch die Transaction stillschweigend verlassen wird.
- Ignorieren von
wherebeiupdateManyoderdeleteManyund damit das Aktualisieren jeder einzelnen Zeile. - Die Annahme, dass
findUniquejeden beliebigen Filter akzeptiert; es wird ein eindeutiges Feld benötigt. - Interpolieren von Benutzereingaben in
$queryRawUnsafe. - Offenhalten einer Transaction während eines awaiteten externen API-Aufrufs.
Wie geht es weiter?
Prisma ist eine Lösung dafür, wie man von TypeScript aus mit einer relationalen Datenbank kommuniziert. Vergleiche es mit Drizzle, bei dem SQL sichtbar bleibt und auf den generierten Client verzichtet wird, sowie mit TypeORM, dem Klassen- und Decorator-Ansatz, der beide bereits überholt hat. Unter all diesen Tools liegt die Datenbank selbst, daher ist der PostgreSQL-Guide ein tieferer Einblick, der sich unabhängig vom gewählten ORM auszahlt. Wenn du dich noch nicht für eine Runtime für den Server entschieden hast, auf dem das alles läuft, beginne mit Node.js.