
About
Prisma ORM patterns for TypeScript backends — schema design, query optimization, transactions, pagination, and critical traps like updateMany returning count not records, $transaction timeouts, migrate dev resetting the DB, @updatedAt skipped on bulk writes, and serverless connection exhaustion.
name: prisma-patterns description: Prisma ORM patterns for TypeScript backends — schema design, query optimization, transactions, pagination, and critical traps like updateMany returning count not records, $transaction timeouts, migrate dev resetting the DB, @updatedAt skipped on bulk writes, and serverless connection exhaustion. origin: ECC
Prisma Patterns
Production patterns and non-obvious traps for Prisma ORM in TypeScript backends. Tested against Prisma 5.x and 6.x. Some behaviors differ from Prisma 4.
Check the Prisma version before applying version-specific patterns:
npx prisma --version
Prisma 5 introduced relationJoins, which can load relations via JOIN rather than separate queries depending on query strategy and configuration. The omit field modifier and prisma.$extends Client Extensions API were also added. Note: relationJoins can cause row explosion on large 1:N relations or deep nested include — benchmark both approaches when relations may return many rows per parent.
When to Activate
- Designing or modifying Prisma schema models and relations
- Writing queries, transactions, or pagination logic
- Using
updateMany,deleteMany, or any bulk operation - Running or planning database migrations
- Deploying to serverless environments (Vercel, Lambda, Cloudflare Workers)
- Implementing soft delete or multi-tenant row filtering
Core Concepts
ID Strategy
| Strategy | Use When | Avoid When |
|---|---|---|
| @default(cuid()) | Default choice — URL-safe, sortable, no collisions | Sequential IDs needed for external systems |
| @default(uuid()) | Interoperability with non-Prisma systems required | High-write tables (random UUIDs fragment B-tree indexes) |
| @default(autoincrement()) | Internal join tables, audit logs | Public-facing IDs (exposes record count) |
Schema Defaults
model User {
id String @id @default(cuid())
email String @unique // @unique already creates an index — no @@index needed
name String
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([createdAt])
@@index([deletedAt, createdAt]) // composite for soft-delete + sort queries
}
- Add
@@indexon every foreign key and column used inWHEREorORDER BY. - Declare
deletedAt DateTime?upfront when soft delete is a foreseeable requirement — adding it later requires a migration on a live table. updatedAt @updatedAtis set automatically by Prisma onupdateandupsertonly (see Anti-Patterns for bulk update trap).
include vs select
| | include | select |
|---|---|---|
| Returns | All scalar fields + specified relations | Only specified fields |
| Use when | You need most fields plus a relation | Hot paths, large tables, avoiding over-fetch |
| Performance | May over-fetch on wide tables | Minimal payload, faster on large datasets |
| Prisma 5 note | Uses JOIN by default (relationJoins) | Same |
// include — all columns + relation
const user = await prisma.user.findUnique({
where: { id },
include: { posts: { select: { id: true, title: true } } },
});
// select — explicit allowlist
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, email: true, name: true },
});
Never return raw Prisma entities from API responses — map to response DTOs to control exposed fields:
// BAD: leaks passwordHash, deletedAt, internal fields
return await prisma.user.findUniqueOrThrow({ where: { id } });
// GOOD: explicit DTO mapping
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
return { id: user.id, name: user.name, email: user.email };
Transaction Form Selection
| Situation | Use | |---|---| | Independent operations, no inter-dependency | Array form | | Later step depends on earlier result | Interactive form | | External calls (email, HTTP) involved | Outside transaction entirely |
// Array form — batched in one round trip
const [user, post] = await prisma.$transaction([
prisma.user.update({ where: { id }, data: { name } }),
prisma.post.create({ data: { title, authorId: id } }),
]);
// Interactive form — use tx client only, never the outer prisma client
const post = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
if (user.role !== 'ADMIN') throw new Error('Forbidden');
return tx.post.create({ data: { title, authorId: user.id } });
});
PrismaClient Singleton
Each PrismaClient instance opens its own connection pool. Instantiate once.
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
});
if (process.env

