Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/prisma-patterns/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 124% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 160% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 266% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 210% | 0% |
| case-21 | ✓→✓ | = Same ✓ | 352% | 0% |
Production patterns and non-obvious traps for Prisma ORM in TypeScript backends.
> Check your version before applying patterns. The Prisma API surface has evolved across major releases: > > bash > npx prisma --version > > > Notable API differences across versions: > - relationJoins can load relations via JOIN rather than separate queries, but may cause row explosion on large 1:N relations or deep include — benchmark both approaches > - omit field modifier and prisma.$extends Client Extensions API were added > - Newer installs: the package may be named prisma instead of @prisma/client; PrismaClient may require a driver adapter (e.g. @prisma/adapter-pg); datasource.url may live in prisma.config.ts instead of schema.prisma > - CLI commands (migrate dev, migrate deploy, generate) are unchanged across versions
updateMany, deleteMany, or any bulk operation| 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) |
prismamodel 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 }
@@index on every foreign key and column used in WHERE or ORDER BY.deletedAt DateTime? upfront when soft delete is a foreseeable requirement — adding it later requires a migration on a live table.updatedAt @updatedAt is set automatically by Prisma on update and upsert only (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 |
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 };
| 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 } }); });
Each PrismaClient instance opens its own connection pool. Instantiate once.
ts// lib/prisma.ts // Option A — adapter-based initialization (required by newer Prisma installs) import { PrismaClient } from '@prisma/client'; // or the generated client path for your setup import { PrismaPg } from '@prisma/adapter-pg'; function createPrismaClient() { const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL!, }); return new PrismaClient({ adapter, log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'], }); } const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const prisma = globalForPrisma.prisma ?? createPrismaClient(); if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; // Option B — direct initialization (older installs, no adapter needed) // import { PrismaClient } from '@prisma/client'; // export const prisma = globalForPrisma.prisma ?? new PrismaClient({ ... });
Use Option A if your Prisma install requires an adapter argument in the PrismaClient constructor. Use Option B if new PrismaClient() works without arguments. Let the compiler tell you which is correct.
The globalThis pattern prevents duplicate instances during hot reload (Next.js, nodemon, ts-node-dev).
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+ relationJoins, the include 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.
tsasync 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 limit + 1 and pop — canonical way to detect hasNextPage without an extra count query. Always include a unique field (e.g. id) as a secondary orderBy 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).
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 } }); // restore
tsimport { Prisma } from '@prisma/client'; // or the generated client path for your setup 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: P2002 unique violation · P2025 not found · P2003 foreign key violation.
Catch at the service boundary and translate to domain errors. Never expose raw Prisma messages to API consumers.
Embed connection params directly in DATABASE_URL — string concatenation breaks if the URL already has query parameters (e.g. ?schema=public):
bash# .env — preferred: embed params in the URL DATABASE_URL="postgresql://user:pass@host/db?connection_limit=1&pool_timeout=20" # With an external 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 controlled via DATABASE_URL // Adapter-based setup (if your Prisma install requires an adapter): import { PrismaClient } from '@prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), }); // Direct setup (if your Prisma install does not require an adapter): // const prisma = new PrismaClient();
updateMany returns a count, not recordsts// 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 deleteMany — returns { count: n }, never the deleted rows.
$transaction interactive form times out after 5 secondsts// 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 });
migrate dev can reset the databasemigrate dev detects schema drift and may prompt to reset the DB, dropping all data.
bash# NEVER on shared dev, staging, or production npx prisma migrate dev --name add_column # Safe everywhere except local solo dev 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"
Prisma checksums every migration file. Editing after apply causes P3006 checksum mismatch on every environment where the original already ran. Create a new migration instead.
Adding NOT NULL to an existing column or renaming a column in one migration will lock the table or drop data. Use expand-and-contract:
bash# Step 1: create migration locally, then deploy 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# Step 3: create the NOT NULL constraint migration locally, then deploy npx prisma migrate dev --name make_new_column_required # local only npx prisma migrate deploy # staging / production
@updatedAt does not fire on updateMany@updatedAt is set automatically only on update and upsert. Bulk writes leave it stale.
ts// 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() }, });
findUniqueOrThrow leaks deleted recordsfindUniqueOrThrow throws P2025 only when the row does not exist in the DB. Soft-deleted rows still exist and are returned without error.
findUniqueOrThrow requires a unique constraint field in where — adding deletedAt: null alongside id breaks the type because { id, deletedAt } is not a compound unique constraint. Use findFirstOrThrow instead.
ts// 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 } });
deleteMany without where deletes every rowts// BAD: silently wipes the table await prisma.post.deleteMany(); // GOOD await prisma.post.deleteMany({ where: { authorId: userId } });
| Rule | Reason | |---|---| | migrate deploy in CI/CD, migrate dev only locally | migrate dev can reset the DB on drift | | Map entities to response DTOs | Prevents leaking internal fields | | Catch PrismaClientKnownRequestError at service boundary | Translate to domain errors | | Prefer *OrThrow methods over manual null checks | Throws P2025 automatically; use findFirstOrThrow when filtering non-unique fields | | connection_limit=1 + external pooler in serverless | Prevents connection exhaustion | | Always provide where on deleteMany | Prevents accidental table wipe | | Set updatedAt: new Date() manually in updateMany | @updatedAt skips bulk writes |
nestjs-patterns — NestJS service layer that integrates Prismapostgres-patterns — PostgreSQL-level indexing and connection tuningdatabase-migrations — multi-step migration planning for productionbackend-patterns — general API and service layer design| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-03 | pass→pass | 7,070 | 7,463 | +6% | 1 | 1 | 0% | 1,388 | 5,085 | +266% | 0 | 0 | — |
case-04 | pass→pass | 9,971 | 11,091 | +11% | 1 | 1 | 0% | 1,954 | 6,060 | +210% | 0 | 0 | — |
case-01 | fail→pass | 11,501 | 6,951 | -40% | 1 | 1 | 0% | 2,324 | 5,211 | +124% | 0 | 0 | — |
case-02 | fail→pass | 10,628 | 7,923 | -25% | 1 | 1 | 0% | 2,097 | 5,456 | +160% | 0 | 0 | — |
case-21 | pass→pass | 5,406 | 4,000 | -26% | 1 | 1 | 0% | 1,008 | 4,559 | +352% | 0 | 0 | — |
case-05 | pass→pass | 5,616 | 4,876 | -13% | 1 | 1 | 0% | 1,159 | 4,779 | +312% | 0 | 0 | — |
case-06 | pass→pass | 7,020 | 6,824 | -3% | 1 | 1 | 0% | 1,393 | 5,122 | +268% | 0 | 0 | — |
case-07 | pass→pass | 6,346 | 4,365 | -31% | 1 | 1 | 0% | 1,294 | 4,747 | +267% | 0 | 0 | — |
case-08 | pass→pass | 6,381 | 5,345 | -16% | 1 | 1 | 0% | 1,319 | 4,896 | +271% | 0 | 0 | — |
case-09 | pass→pass | 14,950 | 4,101 | -73% | 1 | 1 | 0% | 1,013 | 4,602 | +354% | 0 | 0 | — |
case-10 | pass→pass | 5,521 | 2,473 | -55% | 1 | 1 | 0% | 1,018 | 4,250 | +317% | 0 | 0 | — |
case-11 | pass→pass | 3,898 | 1,985 | -49% | 1 | 1 | 0% | 624 | 4,234 | +579% | 0 | 0 | — |
case-12 | pass→pass | 16,560 | 11,761 | -29% | 1 | 1 | 0% | 2,961 | 6,217 | +110% | 0 | 0 | — |
case-22 | pass→pass | 8,339 | 4,704 | -44% | 1 | 1 | 0% | 1,452 | 4,696 | +223% | 0 | 0 | — |
case-13 | pass→pass | 4,105 | 5,342 | +30% | 1 | 1 | 0% | 799 | 4,829 | +504% | 0 | 0 | — |
case-14 | pass→pass | 15,214 | 4,598 | -70% | 1 | 1 | 0% | 2,502 | 4,642 | +86% | 0 | 0 | — |
case-15 | pass→pass | 20,201 | 4,352 | -78% | 1 | 1 | 0% | 1,631 | 4,635 | +184% | 0 | 0 | — |
case-16 | pass→pass | 6,453 | 3,615 | -44% | 1 | 1 | 0% | 1,124 | 4,517 | +302% | 0 | 0 | — |
case-17 | pass→pass | 6,674 | 5,153 | -23% | 1 | 1 | 0% | 1,295 | 4,881 | +277% | 0 | 0 | — |
case-18 | pass→pass | 7,264 | 4,590 | -37% | 1 | 1 | 0% | 1,426 | 4,750 | +233% | 0 | 0 | — |
case-19 | pass→pass | 9,891 | 9,743 | -1% | 1 | 1 | 0% | 2,116 | 5,927 | +180% | 0 | 0 | — |
case-20 | pass→pass | 7,400 | 7,383 | -0% | 1 | 1 | 0% | 1,473 | 5,220 | +254% | 0 | 0 | — |
case-23 | pass→pass | 5,946 | 6,387 | +7% | 1 | 1 | 0% | 1,125 | 5,058 | +350% | 0 | 0 | — |
case-24 | pass→pass | 4,034 | 5,297 | +31% | 1 | 1 | 0% | 656 | 4,801 | +632% | 0 | 0 | — |
case-25 | pass→pass | 4,482 | 4,160 | -7% | 1 | 1 | 0% | 902 | 4,620 | +412% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 25 cases were attempted. The headline lift of +8 percentage points is the difference between those two pass rates over the 25 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
| Model | Method | Date | Lift |
|---|---|---|---|
| gemini-3.6-flash | verified | 7/27/2026 | +14% |
Other measured skills in the registry, with their headline benchmark lift.