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.
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:
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);
}
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 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.
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.
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
pgorpostgres.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
includepulling 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.