Daniel Murillo
Index

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:

  1. Drivers and raw SQL — e.g. pg.
  2. Query builders — Knex, Kysely.
  3. Model/entity ORMs — Sequelize, TypeORM, MikroORM, Objection.
  4. Typed client + declarative schema — Prisma.
  5. 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

ToolCategoryCore idea
pgDriverRaw SQL
Knex.jsQuery builderProgrammatic SQL + migrations
KyselyTyped query builderSQL with static typing
SequelizeClassic ORMModels and associations
TypeORMEntity ORMDecorators, repositories, QueryBuilder
MikroORMData MapperUnit of Work and Identity Map
Objection.jsORM on top of KnexModels + access to the builder
PrismaORM / toolkitDeclarative schema + generated client
DrizzleTyped ORM / builderTS 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

CriterionSQL / pgKnex / KyselyClassic ORMsPrismaDrizzle
SQL controlVery highHighVariableMedium (+ raw)High
TS typingManualKysely strongPer modelsGenerated clientStrong
MigrationsSeparateKnex includesPer toolPrisma MigrateDrizzle Kit
Typical riskBoilerplateAligning typesOpaque abstractionsModel limitsLess “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

  1. Authentication
  2. Observability
  3. SQL and ORMs (this note)
  4. How to test an API
  5. Containers and Twelve-Factor

Related reading

References