Arquitectura · 28 de octubre de 2026 · Daniel Murillo
SQL, query builders y ORMs: Knex, Prisma, Drizzle y compañía
No compares todas las herramientas como si fueran lo mismo. Decide cuánta abstracción necesitas y cuánto control sobre SQL quieres conservar.
No compares todas las herramientas como si fueran el mismo producto. Primero decide cuánta abstracción necesitas y cuánto control sobre SQL quieres conservar.
Introducción
El acceso a datos afecta productividad, rendimiento, pruebas, migraciones y evolución del dominio.
En Node.js / TypeScript hay varias familias:
- Drivers y SQL directo — p. ej.
pg. - Query builders — Knex, Kysely.
- ORMs de modelos/entidades — Sequelize, TypeORM, MikroORM, Objection.
- Cliente tipado + esquema declarativo — Prisma.
- ORM tipado cercano a SQL — Drizzle.
Las categorías se solapan. El objetivo no es un ganador absoluto, sino comparar control, abstracción y costo de mantenimiento.
1. Mapa rápido
| Herramienta | Categoría | Idea central |
|---|---|---|
pg | Driver | SQL directo |
| Knex.js | Query builder | SQL programático + migraciones |
| Kysely | Query builder tipado | SQL con tipado estático |
| Sequelize | ORM clásico | Modelos y asociaciones |
| TypeORM | ORM de entidades | Decoradores, repositorios, QueryBuilder |
| MikroORM | Data Mapper | Unit of Work e Identity Map |
| Objection.js | ORM sobre Knex | Modelos + acceso al builder |
| Prisma | ORM / toolkit | Esquema declarativo + cliente generado |
| Drizzle | ORM / builder tipado | Esquema TS + API cercana a SQL |
Compara siempre la versión vigente y el motor (PostgreSQL, MySQL, etc.).
2. La misma consulta, distintas APIs
Tabla users con id, first_name, email. En SQL usamos snake_case; en TypeScript, camelCase. Mapea explícitamente entre capas.
SQL con pg
const result = await pool.query(
"SELECT id, first_name, email FROM users WHERE id = $1",
[userId],
);
const user = result.rows[0];
Máximo control; más trabajo manual en mapeo, transacciones y convenciones.
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 },
});
Asume @map("first_name") en el esquema para 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);
Estos fragmentos ilustran ergonomía, no rendimiento. Inspecciona siempre el SQL generado en consultas críticas.
3. Comparación por dimensiones
| Criterio | SQL / pg | Knex / Kysely | ORMs clásicos | Prisma | Drizzle |
|---|---|---|---|---|---|
| Control del SQL | Muy alto | Alto | Variable | Medio (+ raw) | Alto |
| Tipado TS | Manual | Kysely fuerte | Según modelos | Cliente generado | Fuerte |
| Migraciones | Aparte | Knex incluye | Según herramienta | Prisma Migrate | Drizzle Kit |
| Riesgo típico | Boilerplate | Alinear tipos | Abstracciones opacas | Límites del modelo | Menos “dominio” que un ORM rico |
4. El problema N+1 (más importante que la marca del ORM)
1 query: SELECT * FROM orders WHERE user_id = ?
N queries: SELECT * FROM items WHERE order_id = ? (por cada orden)
Cualquier capa puede generarlo si cargas relaciones sin cuidado. Soluciones: joins, include/with conscientes, DataLoader, o SQL explícito. Mide con logs de consultas o EXPLAIN antes de culpar al ORM.
5. Migraciones: otra decisión
Evalúa:
- ¿El SQL de migración se revisa en PR?
- ¿Se aplica en CI?
- ¿Qué pasa si falla a mitad?
- ¿Cómo haces backfills grandes?
Patrón habitual en producción: expandir esquema → desplegar código compatible → migrar datos → contraer lo antiguo. No asumas rollback automático seguro.
6. Transacciones
No basta con que exista un método transaction. Comprueba aislamiento, propagación del contexto, rollback y que no dejas la transacción abierta esperando una API externa.
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. Cómo elegir
pg: control total o consultas especializadas.- Knex: builder flexible + migraciones sin ORM pesado.
- Kysely: SQL explícito con tipado fuerte.
- Sequelize / TypeORM / MikroORM / Objection: si el modelo de entidades y el equipo ya encajan; valida el SQL generado.
- Prisma: esquema + cliente generado y DX fuerte.
- Drizzle: tipado cercano a SQL con buen control.
Si ya hay producción, el costo de migración suele pesar más que la moda del ORM.
8. Checklist
- Modelo alineado con el dominio.
- Tipos TS alineados con el esquema.
- Consultas críticas con plan razonable (
EXPLAIN). - Sin N+1 accidental.
- Transacciones donde debe haber atomicidad.
- Migraciones versionadas y probadas.
- Pool, timeouts y errores manejados.
- Carga representativa medida.
Conclusión
No es Prisma vs Drizzle ni ORM vs SQL como guerra de bandos. Es abstracción, control, tipos, dominio y operación.
Primero entiende las consultas y el modelo. Después elige la herramienta que reduzca trabajo accidental sin ocultar lo que debes controlar.
En esta serie
- Autenticación
- Observabilidad
- SQL y ORMs (esta nota)
- Cómo probar una API
- Contenedores y Twelve-Factor
Lecturas relacionadas
- Benchmarks y decisiones de arquitectura
- Optimizar APIs REST
- NestJS, Spring Boot y FastAPI
- Herramienta: qué backend te conviene