Database Design Best Practices with Prisma
Prisma is a powerful ORM that simplifies database operations while maintaining type safety and excellent developer experience. In this comprehensive guide, we'll explore best practices for designing efficient database schemas using Prisma.
Getting Started with Prisma
First, let's set up Prisma in your project:
npm install prisma @prisma/client
npx prisma init
This creates a prisma
directory with your schema file and a .env
file for your database connection.
Schema Design Principles
1. Choose Appropriate Data Types
Selecting the right data types is crucial for performance and storage efficiency:
model User {
id String @id @default(cuid()) // Use cuid() for distributed systems
email String @unique // Unique constraint for email
name String? // Optional field
age Int? // Optional integer
isActive Boolean @default(true) // Boolean with default
salary Decimal? @db.Decimal(10, 2) // Precise decimal for money
createdAt DateTime @default(now()) // Auto-generated timestamp
updatedAt DateTime @updatedAt // Auto-updated timestamp
@@map("users") // Custom table name
}
2. Model Relationships Correctly
Define clear and efficient relationships between your models:
model User {
id String @id @default(cuid())
email String @unique
name String
posts Post[] // One-to-many
profile Profile? // One-to-one
// Many-to-many through join table
categories UserCategory[]
@@map("users")
}
model Post {
id String @id @default(cuid())
title String
content String? @db.Text
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String
tags PostTag[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("posts")
}
model Profile {
id String @id @default(cuid())
bio String? @db.Text
website String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @unique
@@map("profiles")
}
model Category {
id String @id @default(cuid())
name String @unique
users UserCategory[]
posts PostTag[]
@@map("categories")
}
model UserCategory {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
categoryId String
createdAt DateTime @default(now())
@@id([userId, categoryId])
@@map("user_categories")
}
model PostTag {
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
postId String
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
categoryId String
@@id([postId, categoryId])
@@map("post_tags")
}
Indexing Strategy
Proper indexing is crucial for query performance:
model Post {
id String @id @default(cuid())
title String
content String? @db.Text
published Boolean @default(false)
authorId String
createdAt DateTime @default(now())
// Single column indexes
@@index([published])
@@index([createdAt])
@@index([authorId])
// Composite indexes for complex queries
@@index([published, createdAt])
@@index([authorId, published])
@@map("posts")
}
model User {
id String @id @default(cuid())
email String @unique // Automatically creates an index
name String
// Full-text search index (PostgreSQL)
@@index([name], type: Gin)
@@map("users")
}
Query Optimization Techniques
1. Select Only Required Fields
Always specify exactly which fields you need:
// ❌ Bad: Fetches all fields
const users = await prisma.user.findMany()
// ✅ Good: Only fetch required fields
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
},
})
2. Efficient Relation Loading
Use include
and select
strategically:
// ✅ Include related data efficiently
const userWithPosts = await prisma.user.findUnique({
where: { id: userId },
include: {
posts: {
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 10,
select: {
id: true,
title: true,
excerpt: true,
createdAt: true,
},
},
profile: {
select: {
bio: true,
website: true,
},
},
},
})
3. Pagination Best Practices
Implement efficient pagination:
// ✅ Cursor-based pagination (recommended for large datasets)
async function getPaginatedPosts(cursor?: string, take: number = 10) {
return await prisma.post.findMany({
take,
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
excerpt: true,
createdAt: true,
author: {
select: {
name: true,
email: true,
},
},
},
})
}
// ✅ Offset-based pagination (simpler, but less efficient for large datasets)
async function getPaginatedPostsOffset(page: number = 1, limit: number = 10) {
const skip = (page - 1) * limit
const [posts, total] = await Promise.all([
prisma.post.findMany({
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.post.count(),
])
return {
posts,
total,
page,
totalPages: Math.ceil(total / limit),
}
}
Transaction Management
Use transactions for operations that must succeed or fail together:
// ✅ Sequential transactions
async function createUserWithProfile(userData: UserData, profileData: ProfileData) {
return await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: userData,
})
const profile = await tx.profile.create({
data: {
...profileData,
userId: user.id,
},
})
return { user, profile }
})
}
// ✅ Batch transactions (faster for independent operations)
async function batchCreateUsers(usersData: UserData[]) {
const createOperations = usersData.map(userData =>
prisma.user.create({ data: userData })
)
return await prisma.$transaction(createOperations)
}
Error Handling and Validation
1. Database Constraint Handling
import { Prisma } from '@prisma/client'
async function createUser(userData: UserCreateInput) {
try {
return await prisma.user.create({
data: userData,
})
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
// Unique constraint violation
throw new Error('Email already exists')
}
if (error.code === 'P2003') {
// Foreign key constraint violation
throw new Error('Referenced record does not exist')
}
}
throw error
}
}
2. Input Validation with Zod
import { z } from 'zod'
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(120).optional(),
})
async function createUserSafely(input: unknown) {
const validatedData = CreateUserSchema.parse(input)
return await prisma.user.create({
data: validatedData,
})
}
Performance Monitoring and Optimization
1. Enable Query Logging
const prisma = new PrismaClient({
log: [
{ emit: 'event', level: 'query' },
{ emit: 'stdout', level: 'error' },
{ emit: 'stdout', level: 'info' },
{ emit: 'stdout', level: 'warn' },
],
})
prisma.$on('query', (e) => {
console.log('Query: ' + e.query)
console.log('Duration: ' + e.duration + 'ms')
})
2. Connection Pool Configuration
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
// Configure connection pool
// Note: These are typically set in DATABASE_URL
})
Migration Best Practices
1. Safe Migration Patterns
# Generate migration without applying
npx prisma migrate dev --create-only
# Review the generated SQL before applying
# Edit the migration file if needed
# Apply the migration
npx prisma migrate dev
2. Production Migration Strategy
# Generate migration
npx prisma migrate dev
# Deploy to production
npx prisma migrate deploy
# Generate Prisma Client
npx prisma generate
Security Considerations
1. Row Level Security (RLS)
model Post {
id String @id @default(cuid())
title String
content String
authorId String
author User @relation(fields: [authorId], references: [id])
// Use database views or application-level filtering
@@map("posts")
}
2. Secure Query Patterns
// ✅ Filter by user ownership
async function getUserPosts(userId: string, currentUserId: string) {
// Ensure users can only access their own posts
if (userId !== currentUserId) {
throw new Error('Unauthorized')
}
return await prisma.post.findMany({
where: {
authorId: userId,
},
})
}
Testing Database Operations
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
beforeEach(async () => {
// Clean database before each test
await prisma.post.deleteMany()
await prisma.user.deleteMany()
})
afterAll(async () => {
await prisma.$disconnect()
})
test('should create user with profile', async () => {
const user = await prisma.user.create({
data: {
email: 'test@example.com',
name: 'Test User',
profile: {
create: {
bio: 'Test bio',
},
},
},
include: {
profile: true,
},
})
expect(user.profile?.bio).toBe('Test bio')
})
Conclusion
Following these best practices will help you build efficient, scalable database schemas with Prisma:
- Design thoughtfully: Choose appropriate data types and relationships
- Index strategically: Add indexes for frequently queried fields
- Query efficiently: Use select/include wisely and implement proper pagination
- Handle errors gracefully: Implement proper error handling and validation
- Monitor performance: Keep track of query performance and optimize as needed
- Migrate safely: Use proper migration strategies for production deployments
Remember that database design is an iterative process. Start with a solid foundation and refine your schema as your application grows and evolves.
Happy coding with Prisma! 🚀