Prisma ORM patterns — schema design, migrations, type-safe queries, testing, error handling, and performance
96
95%
Does it follow best practices?
Impact
100%
1.28xAverage score across 3 eval scenarios
Passed
No known issues
Patterns ordered by impact. Every section shows WRONG vs RIGHT code.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql" // or "sqlite", "mysql"
url = env("DATABASE_URL")
}
model Product {
id Int @id @default(autoincrement())
name String
description String?
category String
priceCents Int // Money as Int (cents), NEVER Float
orderItems OrderItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category])
}
model Order {
id Int @id @default(autoincrement())
customerName String
status OrderStatus @default(RECEIVED)
totalCents Int
items OrderItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([createdAt])
}
model OrderItem {
id Int @id @default(autoincrement())
orderId Int
productId Int
quantity Int
priceCents Int
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orderId])
@@index([productId])
}
enum OrderStatus {
RECEIVED
PREPARING
READY
PICKED_UP
CANCELLED
}@updatedAt on EVERY model — auto-updates timestamp on write; include on all models including join/pivot tables@@index on every foreign key column and every frequently filtered column — Prisma does NOT auto-index foreign keys (unlike some ORMs)Int (cents), NEVER Float — Float causes rounding errors (e.g., 0.1 + 0.2 !== 0.3)DATABASE_URL from env() — never hardcode connection strings@default(now()) + @updatedAt — include createdAt and updatedAt on every modelonDelete: Cascade on child relations when parent deletion should cascadegenerator client block always present with provider = "prisma-client-js"// Default: no pool settings — can exhaust DB connection limit
const prisma = new PrismaClient();// Connection pool is configured via the DATABASE_URL connection string:
// postgresql://user:pass@host:5432/db?connection_limit=10&pool_timeout=30
//
// Key parameters:
// connection_limit — max connections in pool (default: num_cpus * 2 + 1)
// pool_timeout — seconds to wait for a connection from pool (default: 10)
//
// For serverless (AWS Lambda, Vercel), use connection_limit=1 and Prisma Accelerate or PgBouncer
const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'warn', 'error'] : ['error'],
});// 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', 'warn', 'error'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;include for list/catalog views (fetches all fields of all relations)// Fetches ALL fields from Product and all related data — wasteful for a catalog list
const products = await prisma.product.findMany({
include: { orderItems: true },
});select for list views to reduce payload// List/catalog view: select only needed fields
const catalog = await prisma.product.findMany({
select: { id: true, name: true, category: true, priceCents: true },
orderBy: [{ category: 'asc' }, { name: 'asc' }],
});include for detail views that need full relation data// Detail view: use include to get the order WITH all its items and their products
const orderDetail = await prisma.order.findUnique({
where: { id: orderId },
include: {
items: {
include: { product: true },
},
},
});select — list views, catalog queries, autocomplete, anywhere you don't need all fields. Reduces query size and response payload.include — detail views where you need complete related records (e.g., order with all line items and their products). Use include when you need nested relation data.select and include at the same level — use select with nested select or include at top level.// Filter with enum values
const activeOrders = await prisma.order.findMany({
where: { status: { in: ['RECEIVED', 'PREPARING'] } },
orderBy: { createdAt: 'asc' },
});
// Pagination
const page = await prisma.product.findMany({
skip: 20,
take: 10,
orderBy: { createdAt: 'desc' },
});const orders = await prisma.order.findMany();
// N+1: each call triggers a separate query!
for (const order of orders) {
const items = await prisma.order.findUnique({ where: { id: order.id } }).items();
// Another N queries for products...
}const orders = await prisma.order.findMany({
include: {
items: {
include: { product: true },
},
},
});
// All data loaded in a single query — no N+1
for (const order of orders) {
for (const item of order.items) {
console.log(item.product.name); // Already loaded
}
}tx parameter for ALL operations insideconst order = await prisma.$transaction(async (tx) => {
// IMPORTANT: use `tx` (not `prisma`) for ALL operations inside the transaction
const order = await tx.order.create({
data: {
customerName: input.customerName,
totalCents: calculatedTotal,
status: 'RECEIVED',
items: {
create: input.items.map(item => ({
productId: item.productId,
quantity: item.quantity,
priceCents: item.priceCents,
})),
},
},
include: { items: true },
});
return order;
});// Sequential: runs operations in order, rolls back all if any fails
const [deletedItems, deletedOrder] = await prisma.$transaction([
prisma.orderItem.deleteMany({ where: { orderId } }),
prisma.order.delete({ where: { id: orderId } }),
]);// For long-running transactions, increase timeout (default 5s)
const result = await prisma.$transaction(
async (tx) => {
// ... expensive operations
},
{
maxWait: 5000, // ms to wait to acquire a connection
timeout: 10000, // ms for the transaction to complete
}
);const user = await prisma.user.create({
data: { email }, // Crashes with P2002 if email already exists!
});import { Prisma } from '@prisma/client';
try {
const user = await prisma.user.create({ data: { email } });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
// Unique constraint violation — extract the field from error.meta
const field = (error.meta?.target as string[])?.join(', ');
throw new ConflictError(`${field} already exists`);
}
if (error.code === 'P2025') {
// Record not found (e.g., update/delete on non-existent record)
throw new NotFoundError('Record not found');
}
}
throw error;
}Key Prisma error codes:
// VULNERABLE: SQL injection via user input!
const users = await prisma.$queryRaw`SELECT * FROM "User" WHERE email = '${email}'`;
const result = await prisma.$queryRawUnsafe(`SELECT * FROM "User" WHERE id = ${id}`);// SAFE: Prisma's tagged template literal auto-parameterizes values
const users = await prisma.$queryRaw`SELECT * FROM "User" WHERE email = ${email}`;
// For dynamic table/column names, use Prisma.sql
import { Prisma } from '@prisma/client';
const table = Prisma.sql`"User"`;
const result = await prisma.$queryRaw`SELECT * FROM ${table} WHERE id = ${id}`;Critical distinction: $queryRaw with tagged template literals is SAFE (auto-parameterized). $queryRawUnsafe() with string concatenation is VULNERABLE. Never use $queryRawUnsafe with user input.
// createMany — fast bulk insert (single SQL INSERT)
await prisma.product.createMany({
data: products.map(p => ({
name: p.name,
category: p.category,
priceCents: p.priceCents,
})),
skipDuplicates: true, // Ignore rows that violate unique constraints
});
// updateMany — bulk update matching records
await prisma.order.updateMany({
where: { status: 'RECEIVED', createdAt: { lt: cutoffDate } },
data: { status: 'CANCELLED' },
});
// deleteMany — bulk delete
await prisma.orderItem.deleteMany({
where: { orderId },
});Use createMany/updateMany/deleteMany instead of loops with individual create/update/delete calls.
# Development: create and apply migration
npx prisma migrate dev --name add_order_status_index
# Production: apply pending migrations (no prompts, no reset)
npx prisma migrate deploy
# Reset dev database (drops, recreates, applies all migrations, runs seed)
npx prisma migrate reset
# Prototyping only: push schema without creating migration files
npx prisma db pushKey rules:
prisma migrate dev for development (creates migration files + applies)prisma migrate deploy for production (applies existing migration files only)prisma db push for prototyping only — does NOT create migration files# .env.test — separate database for tests, NEVER use production DB
DATABASE_URL="postgresql://user:pass@localhost:5432/myapp_test"
# Or for SQLite:
# DATABASE_URL="file:./test.db"// test/setup.ts — shared test Prisma client
import { PrismaClient } from '@prisma/client';
// Test client reads DATABASE_URL from .env.test (configured via dotenv or test runner)
const prisma = new PrismaClient();
export { prisma };import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
datasources: { db: { url: 'postgresql://localhost:5432/test' } }, // WRONG: hardcoded!
});
test('creates order', async () => {
// No cleanup from previous tests — flaky!
// No seed data — test depends on existing state!
});import { PrismaClient } from '@prisma/client';
// DB URL comes from environment (.env.test), never hardcoded
const prisma = new PrismaClient();
let testProduct: { id: number; name: string; priceCents: number };
beforeEach(async () => {
// Clean tables in correct order (children before parents) to respect FK constraints
await prisma.orderItem.deleteMany();
await prisma.order.deleteMany();
await prisma.product.deleteMany();
// Seed test data — always create known data in beforeEach for test isolation
testProduct = await prisma.product.create({
data: {
name: 'Test Latte',
category: 'coffee',
priceCents: 450,
},
});
});
afterAll(async () => {
await prisma.$disconnect();
});
test('creates an order with items', async () => {
const order = await prisma.order.create({
data: {
customerName: 'Alice',
totalCents: 450,
status: 'RECEIVED',
items: {
create: [{ productId: testProduct.id, quantity: 1, priceCents: 450 }],
},
},
include: { items: true },
});
expect(order.items).toHaveLength(1);
expect(order.totalCents).toBe(450);
});deleteMany in beforeEach — clean tables in child-to-parent order before each testbeforeEach — create known test data so tests are self-contained and isolated$disconnect() in afterAll — close the Prisma connection pool after tests complete// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.product.createMany({
data: [
{ name: 'Espresso', category: 'coffee', priceCents: 350 },
{ name: 'Latte', category: 'coffee', priceCents: 450 },
{ name: 'Muffin', category: 'pastry', priceCents: 300 },
],
});
console.log('Seed data created');
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());// package.json
{
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}// Soft delete middleware — filter out deleted records automatically
prisma.$use(async (params, next) => {
if (params.action === 'findMany' || params.action === 'findFirst') {
if (!params.args) params.args = {};
if (!params.args.where) params.args.where = {};
if (params.args.where.deletedAt === undefined) {
params.args.where.deletedAt = null; // Only show non-deleted records
}
}
if (params.action === 'delete') {
params.action = 'update';
params.args.data = { deletedAt: new Date() };
}
return next(params);
});generator client block with provider = "prisma-client-js" presentDATABASE_URL from env() — never hardcoded@updatedAt on EVERY model (including join tables)createdAt DateTime @default(now()) on every model@@index on every foreign key column and frequently filtered columnsInt (cents), never FloatonDelete: Cascade on child relation fields where appropriateselect for list/catalog queries; include for detail views with relations$transaction) for multi-model writes; use tx not prisma insideP2002 unique constraint error handled with PrismaClientKnownRequestError$queryRaw), never string interpolationcreateMany/updateMany for bulk operations instead of loopsprisma migrate dev for development; prisma migrate deploy for productionconnection_limit in DATABASE_URLbeforeEach (child-to-parent order) and seed known data$disconnect() in afterAll