prisma-patterns
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePrisma Patterns
Prisma 模式
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:
bash
npx prisma --versionPrisma 5 introduced , which can load relations via JOIN rather than separate queries depending on query strategy and configuration. The field modifier and Client Extensions API were also added. Note: can cause row explosion on large 1:N relations or deep nested — benchmark both approaches when relations may return many rows per parent.
relationJoinsomitprisma.$extendsrelationJoinsincludeTypeScript后端中Prisma ORM的生产级模式与易踩陷阱。
已针对 Prisma 5.x 和 6.x 测试。部分行为与 Prisma 4 不同。
在应用特定版本的模式前,请检查 Prisma 版本:
bash
npx prisma --versionPrisma 5 引入了,它可以根据查询策略和配置,通过JOIN而非单独查询来加载关联数据。同时还新增了字段修饰符和客户端扩展API。注意:在大型1:N关联或深度嵌套场景下,可能导致数据行数暴增——当关联可能为每个父级返回大量数据时,请对两种方式进行基准测试。
relationJoinsomitprisma.$extendsincluderelationJoinsWhen to Activate
适用场景
- Designing or modifying Prisma schema models and relations
- Writing queries, transactions, or pagination logic
- Using ,
updateMany, or any bulk operationdeleteMany - Running or planning database migrations
- Deploying to serverless environments (Vercel, Lambda, Cloudflare Workers)
- Implementing soft delete or multi-tenant row filtering
- 设计或修改Prisma schema模型与关联关系
- 编写查询、事务或分页逻辑
- 使用、
updateMany或任何批量操作deleteMany - 执行或规划数据库迁移
- 部署到无服务器环境(Vercel、Lambda、Cloudflare Workers)
- 实现软删除或多租户数据行过滤
Core Concepts
核心概念
ID Strategy
ID 策略
| Strategy | Use When | Avoid When |
|---|---|---|
| Default choice — URL-safe, sortable, no collisions | Sequential IDs needed for external systems |
| Interoperability with non-Prisma systems required | High-write tables (random UUIDs fragment B-tree indexes) |
| Internal join tables, audit logs | Public-facing IDs (exposes record count) |
| 策略 | 适用场景 | 避免场景 |
|---|---|---|
| 默认选择——URL安全、可排序、无冲突 | 需要为外部系统使用连续ID时 |
| 需要与非Prisma系统互操作时 | 高写入量表格(随机UUID会导致B树索引碎片化) |
| 内部关联表、审计日志 | 面向公众的ID(会暴露记录数量) |
Schema Defaults
Schema 默认配置
prisma
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 on every foreign key and column used in
@@indexorWHERE.ORDER BY - Declare upfront when soft delete is a foreseeable requirement — adding it later requires a migration on a live table.
deletedAt DateTime? - is set automatically by Prisma on
updatedAt @updatedAtandupdateonly (see Anti-Patterns for bulk update trap).upsert
prisma
model User {
id String @id @default(cuid())
email String @unique // @unique已自动创建索引——无需额外添加@@index
name String
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([createdAt])
@@index([deletedAt, createdAt]) // 用于软删除+排序查询的复合索引
}- 为每个外键和用于或
WHERE的字段添加ORDER BY。@@index - 当可预见需要软删除时,提前声明——后续添加需要对在线表格执行迁移。
deletedAt DateTime? - 仅在
updatedAt @updatedAt和update操作时由Prisma自动设置(反模式部分会介绍批量更新陷阱)。upsert
include
vs select
includeselectinclude
vs select
includeselect | | |
|---|---|---|
| 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 ( | Same |
ts
// 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:
ts
// 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 }; | | |
|---|---|---|
| 返回内容 | 所有标量字段 + 指定关联 | 仅指定字段 |
| 适用场景 | 需要大部分字段加关联数据时 | 热点路径、大型表格、避免过度获取数据时 |
| 性能 | 在宽表中可能过度获取数据 | 负载最小化,在大型数据集上速度更快 |
| Prisma 5 注意事项 | 默认使用JOIN( | 无变化 |
ts
// include — 所有列 + 关联数据
const user = await prisma.user.findUnique({
where: { id },
include: { posts: { select: { id: true, title: true } } },
});
// select — 显式白名单
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, email: true, name: true },
});永远不要从API响应中返回原始Prisma实体——映射为响应DTO以控制暴露的字段:
ts
// 错误:会泄露passwordHash、deletedAt等内部字段
return await prisma.user.findUniqueOrThrow({ where: { id } });
// 正确:显式DTO映射
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 |
ts
// 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 } });
});| 场景 | 使用方式 |
|---|---|
| 独立操作、无相互依赖 | 数组形式 |
| 后续步骤依赖前期结果 | 交互式形式 |
| 涉及外部调用(邮件、HTTP) | 完全在事务外执行 |
ts
// 数组形式 — 批量单次往返
const [user, post] = await prisma.$transaction([
prisma.user.update({ where: { id }, data: { name } }),
prisma.post.create({ data: { title, authorId: id } }),
]);
// 交互式形式 — 仅使用tx客户端,绝不要使用外部prisma客户端
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
PrismaClient 单例
Each instance opens its own connection pool. Instantiate once.
PrismaClientts
// 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.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;The pattern prevents duplicate instances during hot reload (Next.js, nodemon, ts-node-dev).
globalThis每个实例都会打开自己的连接池。请只实例化一次。
PrismaClientts
// 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.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;globalThisN+1 Problem
N+1 问题
Loading relations inside a loop issues one query per row.
ts
// BAD: N+1 — one extra query per user
const users = await prisma.user.findMany();
for (const user of users) {
const posts = await prisma.post.findMany({ where: { authorId: user.id } });
}
// GOOD: single query
const users = await prisma.user.findMany({ include: { posts: true } });With Prisma 5+ , the form uses a single JOIN. On large 1:N sets this may increase result set size — benchmark both approaches if the relation can return many rows per parent.
relationJoinsinclude在循环内加载关联数据会为每一行发起一次查询。
ts
// 错误:N+1 — 每个用户额外发起一次查询
const users = await prisma.user.findMany();
for (const user of users) {
const posts = await prisma.post.findMany({ where: { authorId: user.id } });
}
// 正确:单次查询
const users = await prisma.user.findMany({ include: { posts: true } });在Prisma 5+的下,形式会使用单次JOIN。在大型1:N集合场景下,这可能会增加结果集大小——当关联可能为每个父级返回大量数据时,请对两种方式进行基准测试。
relationJoinsincludeCode Examples
代码示例
Cursor Pagination (preferred for feeds and large datasets)
游标分页(适用于信息流和大型数据集)
ts
async function getPosts(cursor?: string, limit = 20) {
const items = await prisma.post.findMany({
where: { published: true },
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }, // secondary sort prevents unstable pagination on duplicate timestamps
],
take: limit + 1,
...(cursor && { cursor: { id: cursor }, skip: 1 }),
});
const hasNextPage = items.length > limit;
if (hasNextPage) items.pop();
return { items, nextCursor: hasNextPage ? items[items.length - 1].id : null };
}Fetch and pop — canonical way to detect without an extra count query. Always include a unique field (e.g. ) as a secondary to prevent unstable pagination when multiple rows share the same timestamp. Use offset pagination only when users need to jump to arbitrary pages (admin tables).
limit + 1hasNextPageidorderByts
async function getPosts(cursor?: string, limit = 20) {
const items = await prisma.post.findMany({
where: { published: true },
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }, // 二级排序可避免重复时间戳导致的分页不稳定
],
take: limit + 1,
...(cursor && { cursor: { id: cursor }, skip: 1 }),
});
const hasNextPage = items.length > limit;
if (hasNextPage) items.pop();
return { items, nextCursor: hasNextPage ? items[items.length - 1].id : null };
}获取条数据后弹出最后一条——这是无需额外计数查询即可检测的标准方式。始终将唯一字段(如)作为二级,以防止多行共享同一时间戳时出现分页不稳定。仅当用户需要跳转到任意页面(如后台表格)时才使用偏移分页。
limit + 1hasNextPageidorderBySoft Delete
软删除
ts
// Always filter explicitly — do not rely on middleware (hides behavior, hard to debug)
const activeUsers = await prisma.user.findMany({ where: { deletedAt: null } });
await prisma.user.update({ where: { id }, data: { deletedAt: new Date() } });
await prisma.user.update({ where: { id }, data: { deletedAt: null } }); // restorets
// 始终显式过滤——不要依赖中间件(会隐藏行为,难以调试)
const activeUsers = await prisma.user.findMany({ where: { deletedAt: null } });
await prisma.user.update({ where: { id }, data: { deletedAt: new Date() } });
await prisma.user.update({ where: { id }, data: { deletedAt: null } }); // 恢复Error Handling
错误处理
ts
import { Prisma } from '@prisma/client';
try {
await prisma.user.create({ data: { email } });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') throw new ConflictError('Email already exists');
if (e.code === 'P2025') throw new NotFoundError('Record not found');
if (e.code === 'P2003') throw new BadRequestError('Referenced record does not exist');
}
throw e;
}Common codes: unique violation · not found · foreign key violation.
P2002P2025P2003Catch at the service boundary and translate to domain errors. Never expose raw Prisma messages to API consumers.
ts
import { Prisma } from '@prisma/client';
try {
await prisma.user.create({ data: { email } });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') throw new ConflictError('邮箱已存在');
if (e.code === 'P2025') throw new NotFoundError('记录不存在');
if (e.code === 'P2003') throw new BadRequestError('引用的记录不存在');
}
throw e;
}常见错误码: 唯一约束冲突 · 未找到 · 外键约束冲突。
P2002P2025P2003在服务层捕获错误并转换为领域错误。永远不要向API消费者暴露原始Prisma错误信息。
Connection Pool — Serverless
连接池——无服务器环境
Embed connection params directly in — string concatenation breaks if the URL already has query parameters (e.g. ):
DATABASE_URL?schema=publicbash
undefined直接在中嵌入连接参数——如果URL已包含查询参数(如),字符串拼接会失效:
DATABASE_URL?schema=publicbash
undefined.env — preferred: embed params in the URL
.env — 推荐:在URL中嵌入参数
DATABASE_URL="postgresql://user:pass@host/db?connection_limit=1&pool_timeout=20"
DATABASE_URL="postgresql://user:pass@host/db?connection_limit=1&pool_timeout=20"
With an external pooler (PgBouncer, Supabase pooler)
使用外部池化工具(PgBouncer、Supabase pooler)
DATABASE_URL="postgresql://user:pass@host/db?pgbouncer=true&connection_limit=1"
```ts
// Vercel, AWS Lambda, and similar serverless runtimes: cap pool to 1 per instance
// connection_limit and pool_timeout are controlled via DATABASE_URL
const prisma = new PrismaClient();DATABASE_URL="postgresql://user:pass@host/db?pgbouncer=true&connection_limit=1"
```ts
// Vercel、AWS Lambda及类似无服务器运行时:将连接池限制为每个实例1个
// connection_limit和pool_timeout通过DATABASE_URL控制
const prisma = new PrismaClient();Anti-Patterns
反模式
updateMany
returns a count, not records
updateManyupdateMany
返回计数而非记录
updateManyts
// BAD: result is { count: 2 } — users[0] is undefined
const users = await prisma.user.updateMany({ where: { role: 'GUEST' }, data: { role: 'USER' } });
// GOOD: capture IDs first, then update, then fetch only the affected rows
const targets = await prisma.user.findMany({
where: { role: 'GUEST' },
select: { id: true },
});
const ids = targets.map((u) => u.id);
await prisma.user.updateMany({ where: { id: { in: ids } }, data: { role: 'USER' } });
const updated = await prisma.user.findMany({ where: { id: { in: ids } } });Same applies to — returns , never the deleted rows.
deleteMany{ count: n }ts
// 错误:结果为{ count: 2 } — users[0]未定义
const users = await prisma.user.updateMany({ where: { role: 'GUEST' }, data: { role: 'USER' } });
// 正确:先捕获ID,再更新,最后仅获取受影响的行
const targets = await prisma.user.findMany({
where: { role: 'GUEST' },
select: { id: true },
});
const ids = targets.map((u) => u.id);
await prisma.user.updateMany({ where: { id: { in: ids } }, data: { role: 'USER' } });
const updated = await prisma.user.findMany({ where: { id: { in: ids } } });deleteMany{ count: n }$transaction
interactive form times out after 5 seconds
$transaction$transaction
交互式形式5秒后超时
$transactionts
// BAD: external call inside transaction exceeds 5s default → "Transaction already closed"
await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email); // external call
await tx.user.update({ where: { id }, data: { emailSent: true } });
});
// GOOD: external calls outside the transaction
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email);
await prisma.user.update({ where: { id }, data: { emailSent: true } });
// Only raise timeout when bulk processing genuinely needs it
await prisma.$transaction(async (tx) => { ... }, { timeout: 30_000 });ts
// 错误:事务内的外部调用超过5秒默认超时 → "Transaction already closed"
await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email); // 外部调用
await tx.user.update({ where: { id }, data: { emailSent: true } });
});
// 正确:外部调用放在事务外
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email);
await prisma.user.update({ where: { id }, data: { emailSent: true } });
// 仅在批量处理确实需要时才延长超时
await prisma.$transaction(async (tx) => { ... }, { timeout: 30_000 });migrate dev
can reset the database
migrate devmigrate dev
可能重置数据库
migrate devmigrate devbash
undefinedmigrate devbash
undefinedNEVER on shared dev, staging, or production
绝不要在共享开发、预发布或生产环境使用
npx prisma migrate dev --name add_column
npx prisma migrate dev --name add_column
Safe everywhere except local solo dev
除本地单人开发外,所有环境都安全
npx prisma migrate deploy
npx prisma migrate deploy
Check drift without applying
检查漂移而不应用
npx prisma migrate diff
--from-migrations ./prisma/migrations
--to-schema-datamodel ./prisma/schema.prisma
--shadow-database-url "$SHADOW_DATABASE_URL"
--from-migrations ./prisma/migrations
--to-schema-datamodel ./prisma/schema.prisma
--shadow-database-url "$SHADOW_DATABASE_URL"
undefinednpx prisma migrate diff
--from-migrations ./prisma/migrations
--to-schema-datamodel ./prisma/schema.prisma
--shadow-database-url "$SHADOW_DATABASE_URL"
--from-migrations ./prisma/migrations
--to-schema-datamodel ./prisma/schema.prisma
--shadow-database-url "$SHADOW_DATABASE_URL"
undefinedManually editing a migration file breaks future deploys
手动编辑迁移文件会破坏未来部署
Prisma checksums every migration file. Editing after apply causes on every environment where the original already ran. Create a new migration instead.
P3006 checksum mismatchPrisma会为每个迁移文件生成校验和。应用后编辑会导致已运行原始迁移的所有环境出现错误。请创建新的迁移文件替代。
P3006 checksum mismatchBreaking schema changes require multi-step migration
破坏性schema变更需要多步迁移
Adding to an existing column or renaming a column in one migration will lock the table or drop data. Use expand-and-contract:
NOT NULLbash
undefined在现有字段添加约束或重命名字段的单次迁移会锁定表格或丢失数据。请使用扩展-收缩法:
NOT NULLbash
undefinedStep 1: create migration locally, then deploy
步骤1:本地创建迁移,然后部署
npx prisma migrate dev --name add_new_column # local only
npx prisma migrate deploy # staging / production
```ts
// Step 2: backfill data (run in a script or migration job, not in the shell)
await prisma.user.updateMany({ data: { newColumn: derivedValue } });bash
undefinednpx prisma migrate dev --name add_new_column # 仅本地
npx prisma migrate deploy # 预发布/生产
```ts
// 步骤2:回填数据(在脚本或迁移任务中运行,不要在shell中)
await prisma.user.updateMany({ data: { newColumn: derivedValue } });bash
undefinedStep 3: create the NOT NULL constraint migration locally, then deploy
步骤3:本地创建添加NOT NULL约束的迁移,然后部署
npx prisma migrate dev --name make_new_column_required # local only
npx prisma migrate deploy # staging / production
undefinednpx prisma migrate dev --name make_new_column_required # 仅本地
npx prisma migrate deploy # 预发布/生产
undefined@updatedAt
does not fire on updateMany
@updatedAtupdateMany@updatedAt
在updateMany
中不会触发
@updatedAtupdateMany@updatedAtupdateupsertts
// BAD: updatedAt stays at its old value
await prisma.post.updateMany({ where: { authorId }, data: { published: true } });
// GOOD
await prisma.post.updateMany({
where: { authorId },
data: { published: true, updatedAt: new Date() },
});@updatedAtupdateupsertts
// 错误:updatedAt保持旧值
await prisma.post.updateMany({ where: { authorId }, data: { published: true } });
// 正确
await prisma.post.updateMany({
where: { authorId },
data: { published: true, updatedAt: new Date() },
});Soft delete + findUniqueOrThrow
leaks deleted records
findUniqueOrThrow软删除 + findUniqueOrThrow
会泄露已删除记录
findUniqueOrThrowfindUniqueOrThrowP2025findUniqueOrThrowwheredeletedAt: nullid{ id, deletedAt }findFirstOrThrowts
// BAD: returns soft-deleted user
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
// BAD: Prisma type error — { id, deletedAt } is not a unique constraint
const user = await prisma.user.findUniqueOrThrow({ where: { id, deletedAt: null } });
// GOOD: findFirstOrThrow supports arbitrary where conditions
const user = await prisma.user.findFirstOrThrow({ where: { id, deletedAt: null } });findUniqueOrThrowP2025findUniqueOrThrowwheredeletedAt: nullid{ id, deletedAt }findFirstOrThrowts
// 错误:返回软删除的用户
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
// 错误:Prisma类型错误——{ id, deletedAt }不是唯一约束
const user = await prisma.user.findUniqueOrThrow({ where: { id, deletedAt: null } });
// 正确:findFirstOrThrow支持任意where条件
const user = await prisma.user.findFirstOrThrow({ where: { id, deletedAt: null } });deleteMany
without where
deletes every row
deleteManywhere无where
的deleteMany
会删除所有行
wheredeleteManyts
// BAD: silently wipes the table
await prisma.post.deleteMany();
// GOOD
await prisma.post.deleteMany({ where: { authorId: userId } });ts
// 错误:静默清空表格
await prisma.post.deleteMany();
// 正确
await prisma.post.deleteMany({ where: { authorId: userId } });Best Practices
最佳实践
| Rule | Reason |
|---|---|
| |
| Map entities to response DTOs | Prevents leaking internal fields |
Catch | Translate to domain errors |
Prefer | Throws P2025 automatically; use |
| Prevents connection exhaustion |
Always provide | Prevents accidental table wipe |
Set | |
| 规则 | 原因 |
|---|---|
在CI/CD中使用 | |
| 将实体映射为响应DTO | 防止泄露内部字段 |
在服务层捕获 | 转换为领域错误 |
优先使用 | 自动抛出P2025;过滤非唯一字段时使用 |
无服务器环境中使用 | 防止连接耗尽 |
| 防止意外清空表格 |
在 | |
Related Skills
相关技能
- — NestJS service layer that integrates Prisma
nestjs-patterns - — PostgreSQL-level indexing and connection tuning
postgres-patterns - — multi-step migration planning for production
database-migrations - — general API and service layer design
backend-patterns
- — 集成Prisma的NestJS服务层
nestjs-patterns - — PostgreSQL层面的索引与连接调优
postgres-patterns - — 生产环境的多步迁移规划
database-migrations - — 通用API与服务层设计
backend-patterns