Most arguments about Prisma are about the wrong thing. The engine binary that people complained about for years is gone. The N+1 problem people warn about is configurable. What actually costs teams time in production is quieter than either, and it usually shows up months after the schema was written.
What the schema actually buys
Prisma's core idea is that one file describes the data model and everything else is derived from it. You write the schema, run prisma generate, and get a client whose types come from that file rather than from an interface someone maintained by hand.
model Project {
id String @id @default(cuid())
slug String @unique
name String
summary String?
status ProjectStatus @default(DRAFT)
ownerId String
owner User @relation(fields: [ownerId], references: [id])
createdAt DateTime @default(now())
@@index([ownerId, createdAt])
}
enum ProjectStatus {
DRAFT
ACTIVE
ARCHIVED
}The payoff is the feedback loop. Rename summary to description and every file that read it fails to compile, in the editor, before anything is committed. Add a required column without a default and the migration step tells you so. That property is worth more on a codebase with four engineers than any individual query-builder feature, because it converts a class of runtime bug into a class of build error.
const projects = await prisma.project.findMany({
where: { ownerId, status: 'ACTIVE' },
select: { id: true, slug: true, name: true },
orderBy: { createdAt: 'desc' },
take: 20,
});
// projects: { id: string; slug: string; name: string }[]select. Nothing here was hand-typed.Migrations get the same treatment. prisma migrate dev diffs the schema against the database and writes the SQL, so schema history lives in the repository as reviewable files rather than in someone's terminal history.
The cost that went away, and the one that replaced it
For most of Prisma's life the client shipped with a query engine written in Rust. It ran as a separate binary, it had to match the target platform, and it made deployment to anything other than a normal Node server awkward. That is the version of Prisma most objections are still aimed at.
It no longer exists. The Rust-free client, built on a TypeScript and WebAssembly query compiler, reached general availability in v6.16.0 and became the default in Prisma 7, released in November 2025. Prisma's own figures put the client bundle at roughly 1.6 MB, down from about 14 MB.
The replacement is a driver adapter, and Prisma 7 requires one. The ORM no longer owns the connection; @prisma/adapter-pg wraps node-postgres and hands the client a pool.
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client';
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
connectionTimeoutMillis: 5_000,
idleTimeoutMillis: 300_000,
max: 10,
});
export const prisma = new PrismaClient({ adapter });This is a better design, and it moves work onto you. Connection tuning that used to happen through URL parameters is now driver configuration. Because v7 talks to Postgres through node-postgres, TLS behaviour changed too: certificate problems that the Rust engine quietly tolerated now surface as connection errors. That is the correct behaviour, and it is still a surprise if you meet it during a deploy rather than while reading the upgrade guide.
Where the costs actually are
None of what follows is a reason to avoid Prisma. They are the four things we check on every project that uses it, because each one is cheap to get right at the start and expensive to discover under load.
Relation loading is a decision you probably have not made
Prisma can load a relation two ways. The query strategy sends one query per table and joins the results in application memory. The join strategy sends a single query, using a LATERAL JOIN with JSON aggregation on PostgreSQL and correlated subqueries on MySQL, so the database builds the nested shape and the application does no stitching.
Neither is universally right. A join keeps round trips down and pushes work onto the database server. Separate queries keep the database doing simple work and let you scale the application tier instead. The problem is that the choice is invisible at the call site unless you make it explicit, and relationLoadStrategy is still behind the relationJoins preview flag, available on PostgreSQL, CockroachDB and MySQL.
// Two queries, stitched in application memory.
const a = await prisma.user.findMany({
relationLoadStrategy: 'query',
select: { id: true, posts: { select: { title: true } } },
});
// One query: LATERAL JOIN plus JSON aggregation, on PostgreSQL.
const b = await prisma.user.findMany({
relationLoadStrategy: 'join',
select: { id: true, posts: { select: { title: true } } },
});
// Fluent API: always two round trips, no matter the strategy.
const posts = await prisma.user
.findUnique({ where: { email } })
.posts();include returns every column
include: { posts: true } returns all scalar fields on every included post. On a table with a content text column or a large JSON blob, that is the entire cost of the query, and it does not appear anywhere in the code as an obvious mistake. A nested select fixes it. The habit worth building is reaching for select first and include only when you genuinely want the whole row.
Pooling in serverless
Each PrismaClient instance owns a pool. On a serverless platform every warm instance that constructs its own client gets its own pool, and Postgres reaches max_connections at traffic levels that do not look impressive on a dashboard. Two things fix it, and you usually need both: a module-scoped singleton so warm invocations reuse one client, and an external pooler such as PgBouncer in front of the database. Prisma's own documentation recommends the same thing. None of this is specific to Prisma; an ORM just makes it easy to forget the pool is there.
Migration workflow
Two constraints are worth knowing before you commit to the migration tooling rather than after. prisma migrate dev needs a shadow database to detect drift, which means the development database user needs permission to create databases — not always available on a managed instance. And Prisma does not generate down migrations. Rolling back is a forward migration you write yourself.
Both are defensible positions. Both are decisions you inherit rather than make, and both surface at the point where you want to reverse a deployment quickly.
Where raw SQL wins
The useful line is not ORM versus SQL. It is whether the query is shaped like objects or shaped like sets.
Application queries are object-shaped. Fetch a project, its owner, its last twenty events. Prisma handles those well and the generated types are worth having. Reporting and analytics are set-shaped, and this is where the query API runs out.
| Query shape | Example | Prisma Client |
|---|---|---|
| Window functions | Running totals, rank() per group, lag() comparisons | Not expressible |
| Recursive CTEs | Org charts, threaded comments, category trees | Not expressible |
DISTINCT ON | Latest row per group in one pass on PostgreSQL | Not expressible |
| Grouping across a relation | Revenue per customer segment | groupBy does not group on relations |
ROLLUP and GROUPING SETS | Subtotals in a single result set | Not expressible |
| Ranked full-text search | tsvector with ts_rank, trigram similarity | Basic search only, no ranking control |
Reaching for SQL here is not a defeat. Writing a window function as a findMany plus a sort plus a reduce is the defeat: it pulls rows the database could have discarded, moves the work to the slowest tier, and produces code nobody can read against the query plan.
TypedSQL is the door out
Prisma's answer is TypedSQL, still behind the typedSql preview flag. You put the query in a .sql file under prisma/sql/, prisma generate --sql compiles it against the live schema, and you get a function whose parameters and result rows are both typed from the real column types.
One detail that surprises people on the first attempt: unless your models use @map, Prisma creates the columns as camelCase, so raw SQL against them needs quoted identifiers.
-- @param {String} $1:ownerId
-- @param {Int} $2:limit
SELECT DISTINCT ON (p.status)
p.status,
p.id,
p.name,
p."createdAt",
count(*) OVER (PARTITION BY p.status) AS "statusTotal"
FROM "Project" p
WHERE p."ownerId" = $1
ORDER BY p.status, p."createdAt" DESC
LIMIT $2;import { latestProjectPerStatus } from '../generated/prisma/sql';
const rows = await prisma.$queryRawTyped(
latestProjectPerStatus(ownerId, 10),
);
// rows is typed from the real column types, not from a hand-written interfacepsql and read its query plan, which you cannot do with a query builder chain.Two limits are worth planning around. prisma generate --sql infers types by connecting to a real database using the URL in prisma.config.ts, so your CI job needs a reachable one and any pending migrations applied before it runs. And TypedSQL cannot build a query whose columns are decided at runtime; that still means $queryRawUnsafe, with the parameterisation on you.
How we decide
The rule we apply on Nexifer Labs projects is boring, which is the point. Prisma owns the application's read and write path. SQL owns anything that aggregates. Nothing gets rewritten because one side is more fashionable.
| Workload | What we reach for |
|---|---|
| CRUD, forms, detail pages | Prisma Client with select |
| Nested reads on a request path | Prisma Client with an explicit relationLoadStrategy |
| Related writes that must succeed together | Nested writes, or $transaction |
| Dashboards, cohorts, ranked search | TypedSQL |
| Backfills and bulk migrations | SQL in a migration, not a script looping over rows |
| Runtime-generated column lists | $queryRawUnsafe, parameterised, reviewed carefully |
The teams that get into trouble are not the ones that use an ORM. They are the ones that never open the escape hatch.
Prisma is a reasonable default for a TypeScript service in 2026, and it is a better one since the engine binary went away. Adopt it for what it is good at, set the relation strategy deliberately, put a pooler in front of the database, and write the analytical queries in SQL where they belong.



