I've used every major Node.js database tool at this point. Raw SQL with pg, Sequelize back when it was the only game in town, TypeORM when decorators were cool, Knex for the query-builder-purists. Each one has made me mass production incidents in ways I'd rather not discuss. And then I tried Prisma, and something clicked.
It wasn't love at first sight. The schema file felt weird. The migration workflow was different from everything I knew. The Rust query engine seemed like unnecessary complexity. But after shipping three production apps with Prisma, I can say this: it's the first ORM where I actually trust my database code. When something compiles, it works. When I refactor a schema, TypeScript tells me every single place I need to update. That's not a small thing -- that's the difference between confident deploys and 3 AM debugging sessions.
Let me walk you through Prisma the way I wish someone had walked me through it: focused on the stuff that actually matters in production, with honest takes on where it falls short.
What Is Prisma and Why Should You Care?
Prisma has three main pieces, and understanding how they fit together saves a lot of confusion:
- Prisma Schema -- a declarative file where you define your models, relations, and database config. This is your single source of truth. Not your TypeScript classes, not your database -- the schema file.
- Prisma Client -- an auto-generated, type-safe database client. Every query is validated at compile time. Misspell a column name? TypeScript catches it before you run the code.
- Prisma Migrate -- generates SQL migrations by diffing your schema against the database. No more writing migration files by hand (unless you want to).
Prisma supports PostgreSQL, MySQL, SQLite, SQL Server, MongoDB, and CockroachDB. I'll use PostgreSQL for this post because that's what most production Node.js apps end up on, but everything applies to the other databases too.
Setting Up Prisma (The Right Way)
Let's get a project running. I'm going to show you a real-ish schema, not a toy "User with a name field" example.
# Initialize a new Node.js project
mkdir my-prisma-app && cd my-prisma-app
npm init -y
# Install Prisma as a dev dependency and the Prisma Client
npm install prisma --save-dev
npm install @prisma/client
# Initialize Prisma (creates prisma/schema.prisma and .env)
npx prisma init
That gives you a prisma/schema.prisma file. Here's a schema that actually resembles something you'd ship:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
password String
role Role @default(USER)
posts Post[]
profile Profile?
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
@@map("users")
}
model Profile {
id Int @id @default(autoincrement())
bio String?
avatar String?
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("profiles")
}
model Post {
id Int @id @default(autoincrement())
title String
slug String @unique
content String?
published Boolean @default(false)
authorId Int
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
categories Category[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([published, createdAt])
@@map("posts")
}
model Category {
id Int @id @default(autoincrement())
name String @unique
posts Post[]
@@map("categories")
}
model Comment {
id Int @id @default(autoincrement())
content String
postId Int
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
authorId Int
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@map("comments")
}
enum Role {
USER
ADMIN
MODERATOR
}
A few things that trip people up:
@@map("users")maps the Prisma model name to the actual table name. I always use this because I want my table names lowercase and plural, but my model names PascalCase and singular.@@index([published, createdAt])is a composite index. If your most common query is "get published posts sorted by date," this index makes it fast. Most tutorials skip indexes, which is a mistake.onDelete: Cascademeans deleting a user deletes their posts, profile, and comments. Think carefully about this -- you might wantSetNullorRestrictinstead.- Optional fields use
?. AString?becomesstring | nullin TypeScript, notstring | undefined. This distinction matters.
Migrations: Where Things Get Real
Your schema is defined. Now you need to create those tables. Two-step process that people constantly conflate: generate creates the TypeScript client, migrate changes the database.
# Generate the Prisma Client
npx prisma generate
# Create and apply a migration
npx prisma migrate dev --name init
prisma migrate dev does three things: generates a SQL migration file, applies it, and re-generates the client. That's convenient during development.
In production, you use a different command:
# Production: only applies pending migrations
npx prisma migrate deploy
# Check status
npx prisma migrate status
# Nuclear option -- drops everything
npx prisma migrate reset
Do not run prisma migrate dev in production. It prompts for input if there's drift and can reset your database in certain scenarios. Use migrate deploy which only applies pending migrations and fails loudly if something's wrong.
There's also npx prisma db push which syncs your schema without creating migration files. I use this during early prototyping when I'm changing the schema every five minutes. Once you're past the prototype stage, switch to proper migrations. You'll need them for rollbacks and team collaboration.
One gotcha: if you add a required column to a table that has data, the migration will fail. You can't add a non-nullable column without a default value to a table with rows. Fix: add a @default value, or make the column optional initially, backfill the data, then make it required in a second migration.
CRUD Operations -- The Fun Part
First, set up the client properly. This singleton pattern is essential -- without it, every hot reload creates a new connection pool:
// src/db.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: ['query', 'info', 'warn', 'error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
That log: ['query'] option prints every SQL query Prisma generates. Lifesaver during development -- you'd be surprised how many queries some innocent-looking calls execute. Turn this off in production.
Creating records:
// Create a user with nested profile
const user = await prisma.user.create({
data: {
email: '[email protected]',
name: 'Anurag',
password: await bcrypt.hash('securepassword', 10),
role: 'ADMIN',
profile: {
create: {
bio: 'Full-stack developer',
avatar: '/images/anurag.jpg',
},
},
},
include: { profile: true },
});
// Bulk create with skipDuplicates
const categories = await prisma.category.createMany({
data: [
{ name: 'JavaScript' },
{ name: 'TypeScript' },
{ name: 'Database' },
],
skipDuplicates: true,
});
That nested profile: { create: {...} } is one of my favorite Prisma features. One call, two inserts, proper foreign key handling.
Reading records:
// Find unique
const user = await prisma.user.findUnique({
where: { email: '[email protected]' },
include: { profile: true, posts: true },
});
// Find many with pagination and relation counts
const posts = await prisma.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 10,
skip: 0,
include: {
author: { select: { name: true, email: true } },
_count: { select: { comments: true } },
},
});
The _count field gives you comment counts without loading all comments. Real difference to API response times when a post has hundreds of comments.
Updating and deleting:
// Upsert -- update if exists, create if not
const user = await prisma.user.upsert({
where: { email: '[email protected]' },
update: { name: 'Updated Name' },
create: {
email: '[email protected]',
name: 'New User',
password: 'hashed_password',
},
});
// Bulk update
const result = await prisma.post.updateMany({
where: { published: false, createdAt: { lt: new Date('2025-01-01') } },
data: { published: true },
});
console.log(`Published ${result.count} old drafts`);
Tip: delete and update throw if the record doesn't exist. Use deleteMany/updateMany if you're not sure -- they return { count: 0 } instead.
Filtering, Pagination, and Relations
The filtering API is expressive enough that I rarely need raw SQL:
const results = await prisma.post.findMany({
where: {
AND: [
{ published: true },
{
OR: [
{ title: { contains: 'prisma', mode: 'insensitive' } },
{ content: { contains: 'prisma', mode: 'insensitive' } },
],
},
],
author: { role: { in: ['ADMIN', 'MODERATOR'] } },
categories: { some: { name: 'TypeScript' } },
createdAt: { gte: new Date('2026-01-01') },
},
orderBy: [{ createdAt: 'desc' }],
});
That query reads almost like English, and it's fully type-safe. Typo mode: 'insensitve'? TypeScript yells at you.
For pagination, you have two options. Offset-based is simple but gets slow for large datasets (skip: 50000 means scanning and discarding 50,000 rows). Cursor-based uses a WHERE clause under the hood and is equally fast on page 2 or page 2,000:
// Cursor-based pagination
const secondPage = await prisma.post.findMany({
where: { published: true },
take: 10,
skip: 1,
cursor: { id: lastPost.id },
orderBy: { id: 'asc' },
});
For infinite scroll UIs, cursor-based is perfect. For traditional page-number UIs, you're stuck with offset.
Transactions and Raw SQL
Interactive transactions let you use results from earlier operations:
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: { email: '[email protected]', name: 'Author', password: 'hashed' },
});
const post = await tx.post.create({
data: {
title: 'My Post',
slug: 'my-post',
content: 'Hello!',
authorId: user.id,
},
});
return { user, post };
});
Be aware: interactive transactions hold a database connection for the entire callback duration. Don't call external APIs inside transactions -- keep them tight and fast.
For complex aggregations, CTEs, or window functions, drop to raw SQL:
const users = await prisma.$queryRaw`
SELECT u.id, u.name, COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON p."authorId" = u.id
WHERE p.published = true
GROUP BY u.id, u.name
ORDER BY post_count DESC
LIMIT 10
`;
Prisma auto-parameterizes tagged template literals. Never use $queryRawUnsafe with user input.
Why the Type Safety Actually Matters
I used to think type-safe database queries were a nice-to-have. Then I shipped a bug where I accessed user.username instead of user.name and it returned undefined for every user for three hours. With Prisma, that's a compile-time error:
const user = await prisma.user.findUnique({
where: { id: 1 },
select: { name: true, email: true },
});
// TypeScript knows: user is { name: string | null; email: string } | null
user?.password; // Compile-time error: 'password' wasn't selected
await prisma.user.findMany({
where: { emal: '[email protected]' }, // Error: typo caught at compile time
});
Rename a column in your schema, run prisma generate, and TypeScript flags every reference to the old name. Try doing that with Sequelize.
Prisma vs Sequelize vs TypeORM: An Honest Take
I've used all three in production. Here's my honest comparison:
Schema Definition: Sequelize uses define() calls or classes. TypeORM uses decorators. Prisma uses a standalone schema file. I prefer Prisma's approach -- the schema is separate from application code and is the undisputed source of truth.
Type Safety: Sequelize's TypeScript support is bolted on and painful. TypeORM is better but some errors only surface at runtime. Prisma's types are generated from the schema -- always in sync. This is a fundamentally different level of reliability.
Migrations: Sequelize makes you write them by hand. TypeORM auto-generates them but I've seen incorrect ones that would've dropped columns. Prisma generates precise diffs.
Performance: Prisma's Rust query engine is slightly faster than Sequelize and comparable to TypeORM. But for 95% of applications, the ORM isn't your bottleneck -- your missing indexes are.
My advice: new TypeScript project? Use Prisma. Existing Sequelize/TypeORM project? Don't rewrite for the sake of it. But for new services, there's no reason not to reach for Prisma.
Comments (0)
No comments yet. Be the first to share your thoughts!