ORMs are great until they're not. After hitting the ceiling on three different projects, we rebuilt our data layer around raw SQL + typed query builders.
We used Prisma on every project for two years. It was good. The DX is excellent, the type safety is genuine, and for standard CRUD — fetching a user with their associated records, paginating a list, creating an entity — it's hard to beat. Then we started hitting the ceiling.
Where Prisma stops being the right tool
The first project that pushed us was a financial analytics dashboard. The queries required: complex window functions for running totals, multiple CTEs with recursive elements, conditional aggregations across related tables, and result sets that didn't map cleanly to any Prisma model. We spent three days trying to express these queries in Prisma's API, fell back to $queryRaw for most of them, and then asked: why are we using Prisma for this at all?
The second project was a high-traffic API. Prisma's query engine adds a layer of indirection — your Node.js process communicates with the Prisma engine (a Rust binary) via a local socket, which then talks to the database. This overhead is negligible for most use cases and significant for latency-sensitive endpoints handling thousands of requests per second.
The N+1 problem at scale
Prisma handles simple relations through automatic joins. Complex nested relations with conditional includes and custom ordering generate query patterns that are hard to inspect and harder to optimise. We had a dashboard endpoint that was making 40+ database queries per request — Prisma was handling each relation fetch separately, and the auto-batching wasn't kicking in as expected. The fix required restructuring our schema relations and using Prisma's $transaction API in ways that undermined the DX benefit.
What we use now
We've settled on a split approach. For new applications:
- Drizzle ORM for standard entity CRUD and simple relations — it's lightweight, generates real SQL you can read, and has excellent TypeScript support without the Prisma engine overhead
- kysely for complex but typed query building — it expresses SQL directly in TypeScript with full type safety, so you get IDE autocomplete on your CTEs
- Raw pg (node-postgres) with tagged template literals for the 5% of queries that need full SQL control
- Database-level constraints and generated columns instead of application-level logic wherever possible
The migration process
We didn't do a big-bang migration. We identified the query patterns causing problems, extracted them to a raw SQL layer first, and migrated the rest of the data layer incrementally. Both approaches can coexist in the same application for the transition period. The most important thing we did was write a comprehensive query performance baseline before migrating — so we could actually demonstrate the improvement.
The average latency improvement on the endpoints we migrated from Prisma's $queryRaw to kysely was 40-60%. Not because Prisma was bad — but because we were finally able to write the query the database wanted to execute, instead of the query that mapped to our ORM model.
When ORMs are still the right call
For most CRUD-heavy applications, standard SaaS platforms, and teams that prioritise developer speed over query-level optimisation — an ORM is still the right choice. The DX benefits are real. The type safety is real. The migration tooling is excellent. We use Prisma on new projects where the data access patterns are straightforward. The lesson isn't 'ORMs are bad' — it's 'know when you've outgrown them.'
