Query Builders vs ORMs

The orders dashboard loaded in 40 milliseconds on my laptop. In production, for a customer who had actually placed 800 orders, it took almost four seconds and briefly exhausted the connection pool. The code was three tidy lines. The database saw 801 separate queries.

That gap is the whole subject here. The code you write is not the code the database runs, and the size of that gap is exactly what you are choosing when you reach for a raw driver, a query builder, or an ORM. Pick wrong and you get either a wall of hand-written SQL you dread touching, or a friendly-looking method call that quietly melts your database at 3am.

So let’s look at the three layers honestly, including where each one leaks.

Three ways to talk to a database

Everything a Node process does with Postgres eventually becomes a SQL string sent over a socket. The only question is who writes that string: you, a thin typed helper, or a thick abstraction that also manages your objects and relations.

more abstractionORMPrisma, Drizzle .query, TypeORM, Sequelize+ objects, relations, migrations, studio toolingx can emit SQL you never would have writtenQuery builderKysely, Drizzle select+ typed columns, autocomplete, the SQL you wrotex you still have to think in SQLRaw driverpg (node-postgres), postgres.js+ every SQL feature, exact controlx stringly typed, you map rows to objects by handPostgreSQL over a socket
The same job at three levels of abstraction. Going up buys convenience; going down buys control and predictability.

None of these is a beginner tier or an expert tier. Senior teams ship raw SQL. Senior teams ship Prisma. The layers are tradeoffs, not a ladder. What follows is what each one actually costs.

Raw SQL with a driver

The bottom layer is a Postgres driver: pg (usually imported as pg, currently 8.x) or the faster tagged-template client postgres.js (3.x). You hand it a SQL string and an array of values, and you get rows back.

import pg from "pg";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

const { rows } = await pool.query(
  `select id, email, amount, created_at
     from orders
    where status = $1
    order by created_at desc
    limit $2`,
  ["paid", 20],
);
// rows: { id, email, amount, created_at }[]  ... but TypeScript thinks it is any[]

Two things are true at once here. You have the entire power of SQL available, every window function and CTE and lateral join, with zero translation layer between what you type and what runs. And you are working blind. $1 and $2 are positional placeholders the driver sends through the parameter channel (never string-concatenated, which is what keeps SQL injection out), but nothing checks that orders has a column called amount, and rows comes back typed as any. Rename a column in the database and this code keeps compiling, then throws at runtime.

postgres.js reads a little nicer because the placeholders are implicit in the template:

import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL);

const orders = await sql`
  select id, email, amount, created_at
    from orders
   where status = ${"paid"}
   order by created_at desc
   limit ${20}
`;

That ${"paid"} is not string interpolation, even though it looks exactly like it. postgres.js turns it into a bound parameter. It is as safe as $1, which trips up people reading the code for the first time.

Raw drivers are the right call more often than the hype suggests: small services, scripts, migrations, anything where the queries are few and you want to see precisely what hits the database. The pain shows up when the app grows. Twenty tables, forty queries, no autocomplete, and every schema change is a silent time bomb until something breaks in production.

Query builders: SQL shaped as typed functions

A query builder keeps you in SQL but makes the SQL typed. You call methods that map one-to-one onto SQL clauses, the builder assembles the string, and crucially, the SQL you get is the SQL you wrote. No hidden joins, no surprise round-trips.

Kysely (0.29 as of mid-2026, and rock stable despite the pre-1.0 number) is the purest example. You describe your database as a TypeScript interface once, then every query is checked against it:

const rows = await db
  .selectFrom("orders")
  .select(["id", "email", "amount", "createdAt"])
  .where("status", "=", "paid")
  .orderBy("createdAt", "desc")
  .limit(20)
  .execute();
// rows is typed { id, email, amount, createdAt }[], no `any` in sight

Misspell emial, select a column that does not exist, compare status to a number, and TypeScript stops you before the code ever runs. The generated SQL is the obvious select id, email, amount, created_at from orders where status = $1 .... What you read is what executes.

Drizzle has a builder in the same spirit, plus a twist worth knowing: your schema is plain TypeScript, and the row types are inferred from it rather than declared separately.

import { pgTable, text, integer, timestamp } from "drizzle-orm/pg-core";
import { eq, desc } from "drizzle-orm";

export const orders = pgTable("orders", {
  id: text("id").primaryKey(),
  email: text("email").notNull(),
  amount: integer("amount"),
  status: text("status", { enum: ["pending", "paid", "refunded"] }).notNull(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
});

const rows = await db
  .select({ id: orders.id, email: orders.email, amount: orders.amount })
  .from(orders)
  .where(eq(orders.status, "paid"))
  .orderBy(desc(orders.createdAt))
  .limit(20);

Same query, three ways, side by side:

Raw SQLQuery builderORMselect id, emailfrom orderswhere status = $1order by created_atlimit 20values: [paid]db.selectFrom(orders).select([id, email]).where(status, paid).orderBy(created_at).limit(20)= one SQL stringorder.findMany({ where: { status }, orderBy: createdAt, take: 20,})= objects outleft to right: closer to objects, further from the raw SQL
One query, three layers. The builder column is nearly a transcription of the SQL; the ORM column trades that closeness for objects.

For a team that already knows SQL, a query builder is often the sweet spot. You keep the mental model of the database, you gain autocomplete and refactor safety, and nothing surprising ever runs. The cost is that you must know SQL. A builder will happily help you write a slow query with a clear conscience.

ORMs: objects, relations, and a thicker layer

An ORM (object-relational mapper) raises the abstraction again. You stop thinking in rows and joins and start thinking in entities and their relations. It hands you objects, manages migrations, and usually ships a GUI to browse your data. The famous one is Prisma; the veterans are TypeORM and Sequelize; and Drizzle’s relational query API lives here too.

Prisma is schema-first. You describe your data in a schema.prisma file, run a generator, and get a fully typed client:

model Order {
  id        String   @id
  email     String
  amount    Int?
  status    String   @default("pending")
  createdAt DateTime @default(now())
  items     Item[]
}
const orders = await prisma.order.findMany({
  where: { status: "paid" },
  orderBy: { createdAt: "desc" },
  take: 20,
  include: { items: true }, // pull each order's line items too
});
// orders[0].items is typed and populated, no join written by you

That include: { items: true } is the pitch. One line, and every order arrives with its items attached as a nested array, fully typed. You never wrote a join. You never thought about foreign keys at call time. For CRUD-heavy apps with lots of relations, this is a genuine productivity jump, and it is why Prisma is everywhere.

Drizzle offers the same object-shaped reads through a separate relational API, distinct from its builder:

const orders = await db.query.orders.findMany({
  where: eq(orders.status, "paid"),
  orderBy: desc(orders.createdAt),
  limit: 20,
  with: { items: true }, // nested items, one query under the hood
});

The store that sells this course runs on Drizzle, and it uses both faces of it: plain pgTable definitions for the schema, the relational db.query.orders.findFirst({ where: eq(...) }) for reads, and the SQL-like db.select().from(...) builder when a query gets specific. That split is common in practice. You live in the comfortable relational API and drop to the builder when you need the control.

The leaky abstraction, concretely

Here is the honest part. The higher you go, the more the abstraction can decide things you did not ask it to decide. Three failure modes show up again and again.

Lazy loading fires N+1

This is the four-second dashboard from the opening. Some ORM patterns, especially older ActiveRecord-style relations, load a related field the moment you access it. Read it inside a loop and you have quietly written one query per iteration.

// looks innocent, is a disaster at scale
const orders = await repo.find({ where: { status: "paid" } }); // 1 query
for (const order of orders) {
  const items = await order.items; // lazily loads → 1 query PER order
  total += items.reduce((s, i) => s + i.price, 0);
}
1 select * from orders where status = $1one round-trip, returns 20 rowsthen your code loops the 20 rows and touches order.items:order #12 select * from items where order_id=$1order #23 select * from items where order_id=$1order #34 select * from items where order_id=$1order #45 select * from items where order_id=$1… 16 more, one per remaining order1 + 20 = 21 round-tripsfix: one join, or with/include, so the DB does the work once
One query to load the list, then one more for every single row. Twenty orders becomes twenty-one round-trips, and it grows with the data.

The fix is to ask for the relation up front so the database assembles it in a single statement, either with a join you write or with the ORM’s eager with / include. This pattern is common enough and nasty enough that it has its own deep dive: N+1 queries. The thing to internalize now is that the loop looked fine. Nothing in the JavaScript hinted at 21 network round-trips. That is the leak.

An innocent include becomes a monster

Eager loading dodges N+1, but the abstraction can overcorrect. A deeply nested include reads clean and pulls the world:

const users = await prisma.user.findMany({
  include: {
    orders: {
      include: {
        items: { include: { product: { include: { reviews: true } } } },
      },
    },
  },
});

Four lines of nesting. Depending on the tool and version, that is either one enormous join fanning out into a Cartesian-ish result set, or a fan of separate queries per level, and either way you may be dragging tens of thousands of rows across the wire to render a page that shows five. The query is correct. It is also a resource bill nobody signed off on. You will only find it by looking at the SQL the ORM actually ran, which is why every serious ORM lets you log generated queries. Turn that on early.

The window function moment

Sooner or later you need something the object API cannot express. A running total. DISTINCT ON. A PERCENTILE_CONT. A recursive CTE. The relational API shrugs, and you drop to raw SQL inside the same tool.

// Drizzle: a window function via the sql`` escape hatch
import { sql } from "drizzle-orm";
const ranked = await db.execute(sql`
  select id, email, amount,
         rank() over (partition by email order by amount desc) as r
    from orders
`);

Prisma’s answer is TypedSQL: you write the query in a .sql file and its generator produces types for the result, so even your raw escape hatch stays typed. Kysely leans on a sql template that composes with the rest of the builder. This is the tell that matters when you choose: you will end up writing raw SQL no matter what you pick. The only question is how gracefully the tool lets you do it, and whether it hands the result back typed or as any. Once you are in raw territory, the skills from indexes and EXPLAIN are what make the query fast, not the ORM.

Where the type safety comes from

Both query builders and modern ORMs promise end-to-end types, but they get there by two different routes, and the difference affects your daily workflow.

Schema-first codegenschema.prismaor a live DBgenerategenerated clienta build stepInferred from schema-as-codepgTable(…) in .tsplain TypeScripttsc infers, no build steptyped queries+ autocompletesame destination, different source of truth and different workflow
Two roads to a typed client: generate one from a schema file or a live database, or infer the types straight from schema you wrote as code.

Schema-first codegen treats one description as canonical and generates the typed client from it. Prisma diffs schema.prisma; a Kysely setup often runs a tool like kysely-codegen that introspects the live database and writes the TypeScript interface for you. The upside is a single obvious source of truth. The cost is a build step: change the schema, regenerate, or your types lie.

Inferred from schema-as-code skips the generator. Drizzle’s pgTable definitions are the types, read directly by the compiler, so typeof orders.$inferSelect is always current with no regeneration. Kysely used by hand sits here too, when you write the Database interface yourself. The upside is no build step and types that can never drift. The cost is that the schema now lives in TypeScript, which some teams find noisier than a dedicated schema language. This is the same TypeScript inference story from TypeScript for JS developers, pointed at your database.

Neither route makes your runtime queries safer than the other. They differ in ergonomics: an extra generate step and a canonical file, versus inference and a schema written in code.

Migrations belong to the tool

One thing the raw-driver route leaves entirely to you: how the database schema changes over time. Pick a query layer and you have usually picked a migration story too. Drizzle ships drizzle-kit, which can generate SQL migrations by diffing your pgTable schema against the database. Prisma has prisma migrate, driven off the .prisma file. Kysely gives you a migration runner but expects you to write the up and down yourself, which is more manual and more explicit.

# Drizzle: diff the TS schema and emit a SQL migration
npx drizzle-kit generate

# Prisma: create and apply a migration from schema.prisma
npx prisma migrate dev --name add_orders_status

The mechanics, ordering, and the ways a migration can wedge a production database are their own topic. See database migrations for that. The point for choosing a query layer: your migration workflow largely comes bundled, so weigh it as part of the package, not an afterthought.

Try it: what SQL does that expression run?

The gap between the call you write and the SQL that runs is the whole lesson. Pick an expression below and watch the SQL it produces, including the one that quietly turns into an N+1 storm. The mapping here is hand-authored to illustrate the shapes; a real tool prints its own SQL if you turn on query logging.

interactiveGenerated SQL viewer

How to actually choose

Ignore the benchmarks-and-stars discourse. The two axes that matter are how comfortable your team is writing SQL and how complex your queries get. Everything else is noise.

team SQL comfortquery complexityhighlowsimple CRUDanalyticalQuery builderKysely or Drizzle select.Typed, no surprises, youalready think in SQL.Builder + raw SQLBuild the routine 90%,drop to sql for windowfunctions and CTEs.ORMPrisma. Fastest way toship CRUD and relationswithout writing joins.ORM, cautiouslyComplex SQL fights theabstraction. Lean on theraw hatch, or learn SQL.
A rough guide, not a rule. Comfort with SQL and query complexity point you at a default; every square still needs a clean path down to raw SQL.

A few honest defaults that fall out of that grid:

  • Ship a product fast, team is more comfortable in JavaScript than SQL, mostly CRUD. Reach for an ORM. Prisma will get you to a working, typed data layer faster than anything, and its migration and studio tooling is genuinely good. Just turn on query logging from day one so N+1 cannot hide.
  • Team knows SQL and wants to keep knowing what runs. A query builder is the sweet spot. Kysely if you want a pure builder with zero opinions about your schema, Drizzle if you like the schema-as-code plus a relational API to fall back into.
  • Analytics, reporting, anything query-heavy. Stay close to SQL. A builder or even the raw driver, because you will be writing SQL that no object API expresses anyway, and you want it front and center.
  • Small service or script. Raw pg or postgres.js. Adding an ORM to five queries is overhead you will feel and never recoup.

The mental model matters more than the pick. Every one of these tools eventually sends a SQL string to Postgres. If you understand what that SQL does, how it uses indexes, and how it behaves inside a transaction, you will write fast, correct data code in any of them, and you will spot the leak when the abstraction springs one. If you do not, no ORM saves you. It just moves the moment you find out from code review to the incident channel.

Summary

  • Every layer ends the same way: a SQL string sent to the database. What differs is who writes it and how much the tool decides for you.
  • Raw drivers (pg, postgres.js) give full SQL power and exact control, at the cost of no type safety and manual row mapping. Great for small or query-heavy code.
  • Query builders (Kysely, Drizzle select) shape SQL as typed function calls. The SQL you get is the SQL you wrote, with autocomplete and refactor safety on top. Best when the team knows SQL.
  • ORMs (Prisma, Drizzle relational queries, TypeORM, Sequelize) trade in objects, relations, migrations, and studio tooling, at the cost of a layer that can emit surprising queries.
  • The abstraction leaks in three predictable places: lazy loading firing N+1, an innocent nested include pulling far too much, and the window-function moment where you drop to raw SQL anyway.
  • Type safety comes either from codegen off a schema file or live database, or from inference over schema written as code. Same destination, different workflow and source of truth.
  • Your migration story usually comes bundled with the query layer, so weigh migrations as part of the choice.
  • Choose on team SQL comfort and query complexity, not hype. The mental model of the underlying SQL outlasts any specific library, and the ecosystem is moving fast enough that today’s pick is a detail, not a destiny.