#TypeScript#DevOps

ORM vs Query Builder vs Raw SQL: How to Choose

ORMs are convenient but may generate bad SQL; raw SQL is flexible but boilerplate-heavy. This article objectively compares the philosophies and fit of Prisma/Drizzle/Kysely/raw SQL: type safety, migrations, N+1, performance, and learning curve, with an opinionated "which project should use which" conclusion.

Key Points

  • Topic Focus: TypeScript、DevOps
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Database access layer selection is the most divisive topic in TypeScript backends. One camp says ORMs are "for people who can't write SQL"—they generate bad SQL and N+1 queries are everywhere. The other camp says hand-writing SQL is "refusing abstraction," and once the team grows it becomes unmaintainable. Both sides are partly right, but neither tells the full story.

The truth is: Prisma, Drizzle, Kysely, and raw SQL sit at completely different abstraction levels and solve different problems. Replace "which is better" with "which fits my project and team better," and the answer becomes much clearer. This article walks through each one's philosophy, code shape, and real-world pitfalls from high to low abstraction, then gives a non-partisan recommendation at the end.

1. The Philosophical Differences Among the Three Data Access Approaches

A data access approach is fundamentally a choice of abstraction level, and the abstraction level determines both how pleasant it is to write and how deep you can drill when problems arise.

  1. ORM (Object-Relational Mapping): High abstraction. You define "models" and "relationships," and the framework translates the object graph into SQL for you. Your mental model is full of business entities; there are no JOINs.
  2. Query Builder: Medium abstraction. You assemble SQL piece by piece with a chainable API—select().from().where()—you can see the structure but don't hand-write strings.
  3. Raw SQL: Low abstraction. You write SQL strings directly; the framework at most does parameterization, connection pooling, and row-to-object mapping for you.

Abstraction is neither "the higher the better" nor "the lower the better." High abstraction saves brainpower and hides details; low abstraction gives control and forces you to manage details yourself. A fast-iterating SaaS dashboard can cut work hours in half with an ORM, while a high-frequency read-heavy payment system might be dragged down by slow queries in the first week of using an ORM. The next four sections break each option down along this line.

2. Prisma: The Schema-First Type-Safety Straight-A Student

Prisma is a typical "heavy ORM." Its core is a schema.prisma file that defines models and relationships, then generates a strongly typed client.

prisma
// schema.prisma
model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  posts Post[]
}

model Post {
  id       Int  @id @default(autoincrement())
  authorId Int
  author   User @relation(fields: [authorId], references: [id])
}
text
const user = await prisma.user.findUnique({
  where: { id: 1 },
  include: { posts: true }, // explicit preload to avoid N+1
});

Prisma's strengths concentrate in three points: the type safety is genuinely derived at compile time (not hand-written interfaces), schema.prisma is the single source of truth, and the migration tool (prisma migrate) is good enough for most business projects. Out of the box, a newcomer can pick it up in half a day.

But its costs are real too. First, the generated SQL is opaque—you write include: { posts: true }, but Prisma may issue multiple queries internally rather than a single JOIN, and you only find out by reading the logs. Second, complex queries are hard to write: multi-table aggregation, window functions, CTEs, ON CONFLICT—you either take a detour or fall back to prisma.$queryRaw to hand-write SQL. Third, the abstraction layer has a performance tax: certain queries on large tables will be noticeably slower than what you'd write by hand.

In one sentence: Prisma fits scenarios with "clear models, mostly CRUD queries, and a team that wants type safety and migrations out of the box." It is not suited for complex reporting or high-frequency hot queries.

3. Drizzle: The Sweet Spot Between Prisma and Kysely

Drizzle is the most worthwhile option to learn in recent years. Its API is SQL-like—the code you write maps almost one-to-one to SQL.

ts
// schema.ts
export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: varchar('email', { length: 255 }).notNull().unique(),
});

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  authorId: integer('author_id').notNull().references(() => users.id),
});

// query
const rows = await db
  .select()
  .from(users)
  .leftJoin(posts, eq(users.id, posts.authorId))
  .where(eq(users.id, 1));

Drizzle's philosophy is a thin abstraction: it doesn't hide SQL. When you write leftJoin it is a LEFT JOIN; when you write eq it is =. The generated SQL is transparent at a glance, and complex queries (CTEs, window functions, raw SQL fragments) are all directly supported. The schema is written in TS, types are inferred automatically from the schema, and you don't need a heavy codegen process. The footprint is also light, with no burden on Edge Runtime or Serverless.

Its "sweetness" lies in its position: the type safety is as strong as Prisma's, but the control over SQL approaches Kysely's. The cost is slightly heavier mental load than Prisma—you really need to know how to read SQL, and chaining errors will produce wrong queries; nested relations aren't as "automatic" as Prisma's include; you use with with the relational-style API, or write the JOIN yourself.

In one sentence: Drizzle suits medium-to-large projects that "want type safety without losing control over SQL." It's the current default recommendation for TS backends.

4. Kysely: A Pure Query Builder With No ORM Baggage

Kysely is a pure query builder; it has only queries, no models. You hand-write a type definition (or generate one from the database schema with a tool), then assemble SQL with a chainable API.

ts
interface User { id: number; email: string; }
interface Post { id: number; author_id: number; }

const rows = await db
  .selectFrom('users')
  .leftJoin('posts', 'posts.author_id', 'users.id')
  .where('users.id', '=', 1)
  .selectAll()
  .execute();

The core difference from Drizzle is: Kysely has no ORM concept at all—no models, no relationship graph, no "object graph." What you get is a "typed constructor for SQL"; rows are rows and tables are tables.

This implies a few things. The upside is zero ORM baggage: no lazy-loading traps, no N+1 caused by "laziness," and the generated SQL is exactly what you write. Complex queries and dynamically assembled conditions (if (filter) query = query.where(...)) are handled very naturally by Kysely. The downside is that you maintain the types yourself (though codegen is possible), relational queries require you to write the JOIN and assemble the result shape yourself, without Prisma's convenience of "just include it and get nested objects."

In one sentence: Kysely suits scenarios that "need precise control over SQL and don't want to introduce any ORM mental load." Reporting systems, microservices with a thin data layer, and projects where the team has solid SQL skills.

5. Raw SQL: The Most Transparent and the Easiest to Misuse

Finally, writing SQL directly. In Node.js this is usually the pg library with template strings, or lightweight libraries with type inference like @database/pg or postgres.

ts
const rows = await sql`
  SELECT u.*, p.*
  FROM users u
  LEFT JOIN posts p ON p.author_id = u.id
  WHERE u.id = ${1}
`;

The benefit of raw SQL is absolute transparency and flexibility: what you write is what executes. Window functions, LATERAL, database-specific syntax, and every performance trick are all available. There's no performance tax from an abstraction layer.

But the cost is also the heaviest. First, type safety is on you: the return type defaults to any, and you either rely on library inference or hand-write it. Second, boilerplate is heavy: mapping, pagination, and dynamic WHERE assembly are all loops you write yourself. Third, SQL injection risk—the template string parameterization above is safe, but the moment you give in to ${variable} to splice a table name/column name/sort field (these can't be parameterized), you've planted a mine. Fourth, the migration tool is your own choice (something like node-pg-migrate); there's no Prisma-style out-of-the-box experience.

In one sentence: Raw SQL suits scenarios with "extremely complex queries, extreme performance requirements, and a team disciplined about injection and migrations." It is not the default option; it's a special-case option.

6. The Pitfalls of Each: N+1, Slow Queries, Injection, Boilerplate

Before choosing, you must see the pitfalls clearly, because they determine each tool's ceiling.

The ORM N+1 trap: ORMs lazy-load relations by default. When you call user.posts inside a loop, each iteration fires a SQL query—100 users means 101 queries. Prisma's solution is explicit include; Drizzle uses with, but the premise is you have to be aware. N+1 isn't an ORM bug; it's an inherent side effect of high abstraction—if you don't think about SQL, you won't think to merge.

Slow SQL generated by ORMs: Prisma's include sometimes splits into multiple queries instead of a JOIN, which is slow on large tables. This kind of slowness often only surfaces in production, because dev databases have little data.

Verbose complex queries in query builders: Writing a 5-table JOIN + subquery + CTE in Kysely/Drizzle produces longer code than raw SQL, with reduced readability. Medium complexity is the sweet spot; extreme complexity is actually less intuitive than raw SQL.

Injection risk in raw SQL: Parameterization blocks values but not table names, column names, or ORDER BY fields. The moment you dynamically splice these (ORDER BY ${sortField}), you've opened an injection vector. Whitelist validation is required.

The boilerplate tax of raw SQL: Every query needs types, mapping, and pagination; as the team grows, everyone tends to write their own thing, with no unified query style.

7. Selection: Which Project Should Use Which (Don't Be Dogmatic)

Here's an actionable conclusion, but first the caveat: there is no silver bullet, only fit.

  • Rapid development, MVP, mostly CRUD, small team: use Prisma. Type safety + migrations + extremely low mental cost can compress "zero to launch" to the fastest possible. Fall back to $queryRaw for the complex-query portion.
  • Medium-to-large projects that want type safety without losing SQL control: use Drizzle. The current default recommendation in the TS ecosystem, with the highest long-term ROI.
  • Reporting systems, thin data layer, solid team SQL skills, need precise SQL control: use Kysely. No ORM baggage; complex dynamic queries are the smoothest.
  • Extreme performance, extremely complex SQL (lots of database-specific syntax, window functions, LATERAL): use raw SQL + a library with type inference. Leave abstraction to where it belongs; don't abstract core queries.

Mixing is also reasonable: Prisma for business CRUD, and Kysely or raw SQL for complex reporting against the same database. Don't force an ORM where an ORM doesn't fit for the sake of "uniformity," and don't force raw SQL in a pure-CRUD spot for the sake of "purity."

Two final anti-dogmas: ORMs are not always bad—they've let countless small and medium projects finish the race in half the time, and N+1 and slow queries are controllable with best practices; raw SQL is not always right—a 5-person SaaS team writing SQL end to end, with migrations, types, and style all left to self-discipline, becomes unmaintainable in half a year. Choose based on project scale, query complexity, and team capability, not on faith.

Summary

  • Abstraction level is the core trade-off: ORMs have high abstraction, saving brainpower and hiding details; query builders have medium abstraction where SQL is visible; raw SQL has low abstraction, giving control and requiring you to manage details yourself.
  • Prisma: strongest type safety, migrations out of the box, suited to rapid CRUD-heavy development; the cost is opaque SQL and hard complex queries.
  • Drizzle: SQL-like API + strong types, a thin abstraction where the generated SQL is visible, the sweet spot between Prisma and Kysely, and the current default recommendation for TS backends.
  • Kysely: a pure query builder with no ORM baggage, suited to scenarios that need precise SQL control and a team with solid skills; relational queries must be assembled yourself.
  • Raw SQL: the most flexible and transparent, but types, migrations, injection, and boilerplate are all your own work; it is a special-case option rather than the default.

Key takeaway: ORMs save brainpower; raw SQL saves the abstraction tax—which side you should pay depends on how complex your queries are and how big your team is, not on which faction you believe in.

Related

Related Content