Prisma ORM made TypeScript database access feel like writing regular TypeScript, and the fully-typed generated client is still the single biggest reason teams pick it over writing raw SQL or using a lower-level query builder.
Prisma is a schema-first ORM for Node.js and TypeScript: you define your data model in a dedicated schema.prisma file, and Prisma generates a fully-typed client plus migration tooling from that single source of truth. Every query you write gets autocomplete and compile-time type checking based on your actual schema — a level of type safety that's genuinely hard to get with hand-written SQL, even with a query builder.
Why Prisma Matters (and When to Skip It)
The generated client eliminates an entire category of bugs — typos in column names, wrong types passed to a query, forgetting a required field — that show up as runtime errors with raw SQL but as compile-time errors with Prisma. Migrations are also handled declaratively: change the schema file, run prisma migrate dev, and Prisma generates and applies the SQL diff for you.
Skip Prisma for workloads needing very fine-grained SQL control — complex window functions, database-specific features Prisma doesn't model well, or extremely performance-sensitive queries where the abstraction overhead (however small) matters. prisma.$queryRaw exists as an escape hatch, but reaching for it constantly is a signal Prisma may not fit that particular workload.
Getting Started with Prisma
Define the schema:
// schema.prisma
model User {
id String @id @default(cuid())
email String @unique
posts Post[]
}
model Post {
id String @id @default(cuid())
title String
author User @relation(fields: [authorId], references: [id])
authorId String
}
Generate the client and run a query:
npx prisma migrate dev --name init
npx prisma generate
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const user = await prisma.user.create({
data: {
email: "suhail@example.com",
posts: { create: { title: "My First Post" } },
},
include: { posts: true },
});
// user.posts is fully typed as Post[], no manual type annotation needed
Core Prisma Concepts Every Developer Should Know
include and select control exactly what data comes back, with full type inference. The return type of a query changes based on what you include — TypeScript knows user.posts exists only if you actually included it:
const userWithPosts = await prisma.user.findUnique({
where: { id: userId },
include: { posts: true },
});
// userWithPosts.posts: Post[]
const userOnly = await prisma.user.findUnique({ where: { id: userId } });
// userOnly.posts would be a type error — it wasn't included
Migrations are generated, reviewable SQL, not magic. prisma migrate dev produces an actual .sql migration file you can read and version-control — worth reviewing before applying to production, especially for destructive changes like column drops.
$transaction wraps multiple operations atomically, same guarantee as raw SQL transactions:
await prisma.$transaction([
prisma.inventory.update({ where: { id: productId }, data: { quantity: { decrement: 1 } } }),
prisma.order.create({ data: { productId, userId } }),
]);
Prisma Client generates fresh after every schema change — forgetting to regenerate after pulling schema changes from a teammate is a common source of "types don't match reality" confusion. Running prisma generate as part of your install/build script avoids this entirely.
Common Prisma Mistakes and How to Fix Them
Mistake 1: N+1 query patterns from looping over records and querying inside the loop. Fetching a list of posts, then querying each post's author individually inside a loop, produces one query per post instead of a single joined query. Fix: use include to fetch related data in the same query, the same fix pattern as the GraphQL N+1 problem.
Mistake 2: running prisma migrate dev directly against production. This command is meant for development — it can prompt for destructive resets in certain conflict scenarios. Fix: use prisma migrate deploy in production/CI, which only applies pending migrations without any interactive reset behavior.
Mistake 3: over-fetching with include when select would return less data. Including full related records when you only need one field wastes bandwidth and adds unnecessary type surface. Fix: use select for precise field-level control when you don't need entire related objects.
When Should You Use Prisma Instead of Drizzle or Raw SQL?
Use Prisma when developer experience, migration tooling, and full type inference matter most, and your queries are mostly standard CRUD with moderate complexity. Consider Drizzle when you want SQL-like query syntax with less abstraction and a lighter runtime footprint, or raw SQL when you need database-specific features or maximum query control that any ORM would get in the way of.
Prisma in Production
Use connection pooling (Prisma Accelerate, or PgBouncer in front of Postgres) in serverless environments — each function invocation potentially opening a new database connection is a real scaling problem without pooling. Also review generated migrations before applying them to production, especially anything involving column type changes or drops, since Prisma's diffing is usually right but not infallible for complex schema changes.
If you're hitting Prisma's abstraction limits on specific queries, $queryRaw with typed results is a reasonable escape hatch — don't abandon the ORM for the whole project over one or two genuinely complex queries.