Overview
Prisma 5 is a TypeScript-first ORM that generates a typed client from a single schema.prisma file. It is the default ORM for Node and Bun services backed by postgres or sqlite. This page covers schema modeling, the migration workflow, transactions, connection pooling, and when to step outside the query builder.
Model the schema explicitly
schema.prisma is the source of truth. Every table, column, index, and relation lives there.
model User {
id String @id @default(cuid())
email String @unique
createdAt DateTime @default(now())
orders Order[]
@@index([createdAt])
}
model Order {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
total Int
@@index([userId, id])
}- Use
@idon every model. Prefercuid()oruuid()over auto-incrementing ints for distributed systems. - Use
@uniquefor natural keys you query by (email, slug). Add@@unique([a, b])for composite uniqueness. - Declare relations on both sides.
onDeleteis explicit and matches the postgresFOREIGN KEYclause. - Add
@@indexfor every query that filters or sorts on a non-key column.
Use migrate dev locally and migrate deploy in production
The two commands serve different masters.
prisma migrate devcreates a new SQL migration from your schema diff, runs it locally, and regenerates the client. Use during development.prisma migrate deployapplies pending migrations in order without prompting. Use in CI and production.- Never edit a migration that has shipped. Write a follow-up migration. See postgres for the migration contract.
- Commit the generated SQL file along with the schema change. Reviewers read the SQL, not the diff of
schema.prismaalone.
Wrap multi-step writes in $transaction
Atomicity is opt-in. Group writes that must succeed or fail together.
await prisma.$transaction([
prisma.order.create({ data: { userId, total } }),
prisma.inventory.update({
where: { sku },
data: { stock: { decrement: 1 } },
}),
]);Use the interactive form for read-modify-write logic:
await prisma.$transaction(async (tx) => {
const item = await tx.inventory.findUnique({ where: { sku } });
if (!item || item.stock < 1) throw new Error("out of stock");
await tx.inventory.update({
where: { sku },
data: { stock: { decrement: 1 } },
});
await tx.order.create({ data: { userId, total, sku } });
});Set isolationLevel: "Serializable" when concurrent writers can race on the same rows.
Pool with PgBouncer or Prisma Accelerate
A single Prisma client opens one connection per concurrent query. Serverless and edge workloads multiply that fast.
- Long-running Node servers: PgBouncer in transaction mode in front of Postgres. Append
?pgbouncer=true&connection_limit=1toDATABASE_URL. - Serverless or edge: Prisma Accelerate or a dedicated pooler (Supabase, Neon). Direct connections from Lambda or Workers will exhaust Postgres under load.
- See postgres for the PgBouncer sizing rules.
Trust the generated client types
The generated @prisma/client is the API contract.
- Run
prisma generateafter every schema change. Wire it intopostinstallso CI never ships a stale client. - Use
Prisma.UserGetPayload<{ include: { orders: true } }>to derive return types instead of writing them by hand. - Keep schema and client in sync across services that share a database. Drift produces silent runtime errors.
Drop to $queryRaw when the query builder is in the way
The query builder is excellent for CRUD. It is poor at recursive CTEs, window functions, JSONB path queries, full-text search, and any plan you need to hand-tune.
const rows = await prisma.$queryRaw<Array<{ id: string; rank: number }>>`
SELECT id, ts_rank(search_tsv, plainto_tsquery(${q})) AS rank
FROM articles
WHERE search_tsv @@ plainto_tsquery(${q})
ORDER BY rank DESC
LIMIT 20;
`;Use the tagged template form. It parameterizes values and prevents injection. Reach for $executeRaw for writes that the builder cannot express.