Architecture · October 28, 2026 · Daniel Murillo
SQL, query builders, and ORMs: Knex, Prisma, Drizzle, and company
Do not compare every tool as if they were the same thing. Decide how much abstraction you need and how much control over SQL you want to keep.
Do not compare every tool as if they were the same product. First decide how much abstraction you need and how much control over SQL you want to keep.
Introduction
Data access affects productivity, performance, tests, migrations, and how the domain evolves.
In Node.js / TypeScript there are several families:
- Drivers and raw SQL — e.g.
pg. - Query builders — Knex, Kysely.
- Model/entity ORMs — Sequelize, TypeORM, MikroORM, Objection.
- Typed client + declarative schema — Prisma.
- Typed ORM close to SQL — Drizzle.
The categories overlap. The goal is not an absolute winner, but to compare control, abstraction, and maintenance cost.
1. Quick map
| Tool | Category | Core idea |
|---|---|---|
pg | Driver | Raw SQL |
| Knex.js | Query builder | Programmatic SQL + migrations |
| Kysely | Typed query builder | SQL with static typing |
| Sequelize | Classic ORM | Models and associations |
| TypeORM | Entity ORM | Decorators, repositories, QueryBuilder |
| MikroORM | Data Mapper | Unit of Work and Identity Map |
| Objection.js | ORM on top of Knex | Models + access to the builder |
| Prisma | ORM / toolkit | Declarative schema + generated client |
| Drizzle | Typed ORM / builder | TS schema + SQL-close API |
Always compare the current version and the engine (PostgreSQL, MySQL, etc.).
2. The same query, different APIs
A users table with id, first_name, email. In SQL we use snake_case; in TypeScript, camelCase. Map explicitly between layers.
SQL with pg
const result = await pool.query(
"SELECT id, first_name, email FROM users WHERE id = $1",
[userId],
);
const user = result.rows[0];
Maximum control; more manual work for mapping, transactions, and conventions.
Knex
const user = await knex("users")
.select("id", "first_name", "email")
.where({ id: userId })
.first();
Kysely
const user = await db
.selectFrom("users")
.select(["id", "first_name", "email"])
.where("id", "=", userId)
.executeTakeFirst();
Prisma
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, firstName: true, email: true },
});
Assumes @map("first_name") in the schema for firstName.
Drizzle
const [user] = await db
.select({
id: users.id,
firstName: users.firstName,
email: users.email,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
These snippets illustrate ergonomics, not performance. Always inspect the generated SQL for critical queries.
3. Comparison by dimensions
| Criterion | SQL / pg | Knex / Kysely | Classic ORMs | Prisma | Drizzle |
|---|---|---|---|---|---|
| SQL control | Very high | High | Variable | Medium (+ raw) | High |
| TS typing | Manual | Kysely strong | Per models | Generated client | Strong |
| Migrations | Separate | Knex includes | Per tool | Prisma Migrate | Drizzle Kit |
| Typical risk | Boilerplate | Aligning types | Opaque abstractions | Model limits | Less “domain” than a rich ORM |
4. The N+1 problem (more important than the ORM brand)
1 query: SELECT * FROM orders WHERE user_id = ?
N queries: SELECT * FROM items WHERE order_id = ? (for each order)
Any layer can produce it if you load relations carelessly. Fixes: joins, deliberate include/with, DataLoader, or explicit SQL. Measure with query logs or EXPLAIN before blaming the ORM.
5. Migrations: another decision
Evaluate:
- Is migration SQL reviewed in PRs?
- Is it applied in CI?
- What happens if it fails halfway?
- How do you do large backfills?
A common production pattern: expand the schema → deploy compatible code → migrate data → contract the old parts. Do not assume automatic rollback is safe.
6. Transactions
It is not enough that a transaction method exists. Check isolation, context propagation, rollback, and that you do not leave the transaction open waiting on an external API.
await knex.transaction(async (trx) => {
const [order] = await trx("orders")
.insert({ customer_id: customerId, status: "pending" })
.returning(["id"]);
await trx("order_items").insert(
items.map((item) => ({
order_id: order.id,
product_id: item.productId,
quantity: item.quantity,
})),
);
});
7. How to choose
pg: full control or specialized queries.- Knex: flexible builder + migrations without a heavy ORM.
- Kysely: explicit SQL with strong typing.
- Sequelize / TypeORM / MikroORM / Objection: if the entity model and the team already fit; validate the generated SQL.
- Prisma: schema + generated client and strong DX.
- Drizzle: typing close to SQL with good control.
If you already have production traffic, migration cost usually outweighs ORM fashion.
8. Checklist
- Model aligned with the domain.
- TS types aligned with the schema.
- Critical queries with a reasonable plan (
EXPLAIN). - No accidental N+1.
- Transactions where there must be atomicity.
- Versioned and tested migrations.
- Pool, timeouts, and errors handled.
- Representative load measured.
Conclusion
It is not Prisma vs Drizzle or ORM vs SQL as a tribal war. It is abstraction, control, types, domain, and operations.
First understand the queries and the model. Then choose the tool that reduces accidental work without hiding what you must control.
In this series
- Authentication
- Observability
- SQL and ORMs (this note)
- How to test an API
- Containers and Twelve-Factor
Related reading
- Benchmarks and architecture decisions
- Optimizing REST APIs
- NestJS, Spring Boot, and FastAPI
- Tool: which backend fits you