¿Qué es Knex.js?
Knex.js es un constructor de consultas SQL para Node.js. Deliberadamente, no es un ORM. No mapea filas a clases, no rastrea el estado de los objetos y no carga relaciones por sí solo. Lo que hace es permitirte construir sentencias SQL como objetos de JavaScript componibles, vincular cada valor de forma segura y ejecutarlas contra Postgres, MySQL, SQLite, MSSQL u Oracle.
Esa restricción es la razón por la cual ha perdurado. Knex apareció en 2013 y se convirtió en la base sobre la cual se construyeron Objection.js y varias otras librerías. Si buscas la ergonomía de un ORM, puedes añadir uno encima; si prefieres mantenerte cerca de SQL, Knex ya se encuentra en el nivel adecuado.
El modelo mental es sencillo: comienza con el nombre de una tabla, encadena métodos para describir la sentencia y luego await para ejecutarla. Cada cadena se convierte finalmente en una consulta parametrizada.
¿Por qué usar un query builder?
Escribir SQL mediante template strings parece una buena idea hasta que una consulta necesita cambiar su estructura. Un endpoint de búsqueda con filtros opcionales, un ordenamiento que dependa de la entrada del usuario y una cláusula de paginación son difíciles de ensamblar mediante concatenación, y peligrosos si algún valor se interpola directamente.
Un builder resuelve ambos problemas. Las condiciones se añaden de forma limpia, los valores se convierten en parámetros vinculados (bound parameters) y los identificadores se encierran entre comillas según el dialecto de destino.
const query = db("users").select("id", "email");
if (search) {
query.where("email", "ilike", `%${search}%`);
}
if (verifiedOnly) {
query.whereNotNull("email_verified_at");
}
const users = await query.orderBy("created_at", "desc").limit(20);
Observa que search se pasa como un argumento a where, no se inserta directamente en una cadena. Knex lo envía al driver como un placeholder, por lo que nunca podrá alterar la estructura de la sentencia.
La otra ventaja es la portabilidad. La misma cadena del builder funciona en PostgreSQL y MySQL con solo un cambio de configuración, lo cual es muy útil para el desarrollo local y las pruebas con SQLite.
Instalación y el knexfile
Instala Knex y el driver de tu base de datos. El driver es un paquete independiente, ya que Knex no incluye los clientes de base de datos.
pnpm add knex pg
pnpm add -D @types/pg
La configuración reside en un knexfile, con una entrada por entorno. La CLI lo lee automáticamente y tu aplicación importa ese mismo objeto.
import type { Knex } from "knex";
const config: { [key: string]: Knex.Config } = {
development: {
client: "pg",
connection: process.env.DATABASE_URL,
migrations: { directory: "./migrations" },
seeds: { directory: "./seeds" },
pool: { min: 2, max: 10 },
},
production: {
client: "pg",
connection: process.env.DATABASE_URL,
pool: { min: 2, max: 10 },
},
};
export default config;
Crea una única instancia compartida e impórtala en todas partes, en lugar de llamar a knex() en cada módulo. Una sola instancia gestiona un pool de conexiones, y crear instancias adicionales multiplica las conexiones hacia la base de datos.
import knex from "knex";
import config from "../knexfile";
export const db = knex(config.development);
Utiliza satisfies de TypeScript o un objeto de configuración tipado para que el editor detecte cualquier opción mal escrita, y mantén los secretos en el entorno en lugar de en el archivo.
Migraciones con el schema builder
Las migraciones son la forma en que el esquema cambia con el tiempo. Cada archivo tiene un up que aplica el cambio y un down que lo revierte.
npx knex migrate:make create_users
npx knex migrate:latest
npx knex migrate:rollback
npx knex migrate:list
El archivo generado es TypeScript convencional. El schema builder describe las tablas mediante un callback que recibe un objeto table.
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable("users", (table) => {
table.increments("id").primary();
table.string("email", 255).notNullable().unique();
table.string("display_name", 120).notNullable();
table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("users");
}
Knex registra las migraciones aplicadas en una tabla knex_migrations, para que cada entorno sepa exactamente qué archivos ha ejecutado. Escribe siempre un down real; aunque rara vez hagas un rollback, esto documenta cómo deshacer el cambio y permite probar las migraciones.
alterTable modifica una tabla existente. Algunos cambios, como añadir una columna notNullable a una tabla que ya contiene filas, requieren un valor por defecto o un paso de backfill. Divide esos casos en dos migraciones: una para añadir la columna y realizar el backfill, y otra para añadir la restricción.
Seeds para datos locales
Los seeds pueblan una base de datos con filas conocidas para el desarrollo y las pruebas. Están separados de las migraciones porque representan datos, no estructura.
import type { Knex } from "knex";
export async function seed(knex: Knex): Promise<void> {
await knex("users").del();
await knex("users").insert([
{ email: "[email protected]", display_name: "Ada Lovelace" },
{ email: "[email protected]", display_name: "Linus Torvalds" },
]);
}
Ejecútalos con npx knex seed:run. Los seeds deben ser idempotentes siempre que sea posible, lo que generalmente significa limpiar la tabla o usar un upsert antes de insertar, para que ejecutarlos dos veces no provoque errores ni duplique los datos.
Construcción de consultas
Una consulta comienza con el nombre de una tabla. A partir de ahí, se encadenan métodos hasta que se espera la resolución de la sentencia.
const rows = await db("users")
.select("id", "email", "display_name")
.where("created_at", ">", since)
.whereIn("role", ["admin", "editor"])
.orderBy("created_at", "desc")
.limit(50)
.offset(0);
where acepta varias formas: una columna y un valor, una columna, un operador y un valor, o un objeto de igualdades. whereIn, whereNot, whereNull, whereBetween y whereExists cubren el resto de los predicados comunes. Las condiciones agrupadas utilizan un callback para que los paréntesis se coloquen en el lugar correcto.
db("posts")
.where("published", true)
.andWhere((qb) => {
qb.where("title", "ilike", `%${term}%`).orWhere("body", "ilike", `%${term}%`);
});
Usa .first() cuando esperes una sola fila, y pluck cuando quieras un array plano de una sola columna.
const user = await db("users").where({ email }).first();
const emails = await db("users").pluck("email");
Joins, agregados y group by
Los joins se leen de la misma manera que en SQL: una tabla y luego el par de columnas que la vinculan.
const rows = await db("users as u")
.join("posts as p", "p.author_id", "u.id")
.whereNot("p.status", "draft")
.groupBy("u.id", "u.email")
.select("u.email")
.count("p.id as post_count")
.max("p.created_at as last_post_at")
.orderBy("post_count", "desc")
.limit(10);
join es un inner join, leftJoin mantiene las filas no coincidentes de la primera tabla, y rightJoin y fullOuterJoin están disponibles en los dialectos que los soportan. La condición del join también puede ser un callback cuando se requiere más de una cláusula.
Los agregados utilizan .count(), .sum(), .avg(), .min() y .max(). Hay dos detalles que suelen causar confusión. Primero, cualquier columna no agregada en el select debe aparecer en groupBy. Segundo, en Postgres los valores de count y sum se devuelven como strings para evitar el desbordamiento de enteros (integer overflow), por lo que debes convertirlos o parsearlos cuando necesites números.
const { count } = await db("users").count("* as count").first();
const total = Number(count);
La cláusula returning
Insertar una fila generalmente implica querer obtener la clave primaria generada o los valores predeterminados del servidor. En Postgres y MSSQL, .returning() le solicita a la base de datos que devuelva la fila.
const [user] = await db("users")
.insert({ email, display_name: displayName })
.returning(["id", "email", "created_at"]);
Las actualizaciones y eliminaciones también pueden devolver filas:
const updated = await db("posts")
.where({ id })
.update({ title, updated_at: db.fn.now() })
.returning("*");
MySQL y las versiones antiguas de SQLite no admiten RETURNING. En esos casos, la llamada de inserción resuelve al nuevo id y debes ejecutar un select posterior. Saber a qué dialecto te diriges es importante, ya que este es uno de los puntos donde la promesa de portabilidad tiene límites.
Upserts y manejo de conflictos
Un patrón común es “insertar esta fila, pero actualizarla si ya existe”. Knex lo expresa mediante onConflict, que se traduce como ON CONFLICT en Postgres y SQLite, y como ON DUPLICATE KEY UPDATE en MySQL.
await db("users")
.insert({ email, display_name: displayName, last_seen_at: db.fn.now() })
.onConflict("email")
.merge({
display_name: displayName,
last_seen_at: db.fn.now(),
});
onConflict recibe la columna o columnas únicas que definen el conflicto. merge actualiza las columnas enumeradas con los datos de la nueva fila, mientras que ignore omite la inserción por completo. Esta es la forma segura de hacer que un proceso de sincronización o un manejador de webhooks sea idempotente, ya que la base de datos resuelve la condición de carrera entre dos escritores concurrentes en lugar de hacerlo el código de tu aplicación.
Transacciones
Una transacción agrupa sentencias para que se confirmen (commit) o se reviertan (roll back) en conjunto. db.transaction pasa un objeto de transacción al callback, y cada sentencia en su interior debe utilizarlo.
await db.transaction(async (trx) => {
const account = await trx("accounts").where({ id: fromId }).first();
if (!account || account.balance_cents < 5000) {
throw new Error("insufficient_funds");
}
await trx("accounts").where({ id: fromId }).decrement("balance_cents", 5000);
await trx("accounts").where({ id: toId }).increment("balance_cents", 5000);
});
Lanzar una excepción revierte la transacción y rechaza la promesa. Retornar un valor la confirma. El error más común es mezclar trx y db dentro del mismo bloque: las consultas ejecutadas en db utilizan una conexión diferente del pool y se ejecutan fuera de la transacción, por lo que no se revierten.
Para un control manual, db.transaction() sin un callback devuelve un objeto de transacción con los métodos commit y rollback. Se recomienda preferir la forma de callback; es más difícil provocar una fuga de conexiones al olvidar realizar el commit.
Consultas raw cuando las necesites
El builder no puede expresar todo, y no intenta hacerlo. knex.raw ejecuta una cadena SQL con valores vinculados.
const result = await db.raw(
`select date_trunc('day', created_at) as day, count(*) as signups
from users
where created_at >= ?
group by 1
order by 1`,
[since],
);
const rows = result.rows;
Usa siempre marcadores de posición ? y pasa los valores en el array. Interpolarlos directamente en la cadena reintroduce exactamente el riesgo de inyección que el builder existe para eliminar. Puedes incrustar un fragmento raw dentro de una cadena del builder con whereRaw o select(db.raw(...)) cuando solo una parte de la consulta requiera SQL escrito a mano.
Las window functions, los CTEs recursivos, COPY y los operadores específicos de cada dialecto son razones válidas para recurrir a raw. Una consulta de ranking, por ejemplo, queda más clara escrita directamente que ensamblada a partir de fragmentos del builder:
const { rows } = await db.raw(
`select email, score,
row_number() over (order by score desc) as rank
from leaderboard
where season = ?`,
[season],
);
Mantén estos fragmentos pequeños y comentados, y prefiere el builder para todo lo que los rodee.
Connection pooling
Cada consulta se ejecuta a través de un pool de conexiones gestionado por tarn.js. Por defecto, el mínimo es de dos y el máximo de diez, lo cual es razonable para un único proceso, pero requiere ajustes en producción.
const db = knex({
client: "pg",
connection: process.env.DATABASE_URL,
pool: { min: 2, max: 10, acquireTimeoutMillis: 30_000 },
});
Dimensiona el pool basándote en el max_connections de la base de datos, distribuido entre todas las instancias de la aplicación. Un pool demasiado grande es tan perjudicial como uno demasiado pequeño: demasiadas conexiones agotan la memoria del servidor y la tabla de procesos. Implementa un pooler como PgBouncer delante cuando ejecutes muchas instancias o funciones serverless.
Llama a await db.destroy() cuando el proceso se detenga. Sin esto, las conexiones abiertas mantienen vivo el event loop y el cierre controlado (graceful shutdown) se queda colgado.
Combinando Knex con Objection.js
Knex devuelve filas simples, por lo que si buscas modelos, relaciones y hooks de ciclo de vida, añade Objection.js. Está construido directamente sobre Knex y reutiliza la misma conexión.
import { Model } from "objection";
import { db } from "./db";
Model.knex(db);
class User extends Model {
static tableName = "users";
static relationMappings = {
posts: {
relation: Model.HasManyRelation,
modelClass: Post,
join: { from: "users.id", to: "posts.author_id" },
},
};
}
const user = await User.query()
.withGraphFetched("posts")
.findOne({ email });
Esta estructura en capas es la respuesta pragmática para los equipos que quieren un ORM pero detestan las abstracciones pesadas: Knex se encarga del SQL y las migraciones, Objection añade el modelo de objetos, y puedes volver a Knex en cualquier momento para realizar una consulta que no encaje con la capa de modelos.
Sigues escribiendo SQL
Lo más importante que debes interiorizar sobre Knex es que no oculta el SQL. Lo reordena, lo parametriza y le añade comillas, pero la sentencia que produce es exactamente lo que habrías escrito tú.
Esto tiene dos consecuencias. La primera es positiva: leer una cadena de Knex te indica cuál es la consulta, y depurar significa imprimir .toSQL() y leer el plan, en lugar de intentar adivinar el SQL generado.
La segunda es una responsabilidad: un builder no te salvará de un índice faltante, un SELECT * sobre una tabla extensa o un cross join accidental. Tú sigues diseñando el esquema, añadiendo los índices y verificando EXPLAIN ANALYZE. Knex elimina la gestión tediosa de strings, no la necesidad de entender la base de datos.
Mejores prácticas
- Crea una sola instancia de Knex e impórtala; nunca llames a
knex()por módulo. - Vincula cada valor como un parámetro y utiliza marcadores de posición
?en las consultas raw. - Versiona cada cambio de esquema como una migración con un método
downreal. - Divide los cambios riesgosos en migraciones separadas: primero añade y realiza el backfill, y luego aplica las restricciones.
- Utiliza
.returning()donde el dialecto lo soporte; de lo contrario, recurre a un select posterior. - Utiliza siempre el objeto de transacción dentro de
db.transaction, nunca la instancia de nivel superior. - Dimensiona el pool de conexiones basándote en el límite de la base de datos y llama a
db.destroy()al apagar el servicio. - Convierte los resultados de Postgres
countysuma números antes de realizar operaciones aritméticas. - Imprime
.toSQL()cuando una consulta te sorprenda y confirma el rendimiento conEXPLAIN ANALYZE.
Errores comunes
- Mezclar
trxydben una sola transacción, provocando que parte del trabajo escape del rollback. - Interpolar valores en cadenas de
knex.rawen lugar de utilizar placeholders. - Olvidar el
groupBypara columnas no agregadas y provocar un error de SQL. - Tratar la cadena devuelta por
countcomo un número y producir"10" + 1. - Asumir que
.returning()funciona de manera idéntica en MySQL y SQLite. - Dejar un pool sin límites o más grande de lo que la base de datos puede soportar.
- Crear una nueva instancia de Knex por cada solicitud y agotar las conexiones.
- Editar un esquema de producción manualmente y permitir que los entornos diverjan.
- Ignorar el método
downhasta que realmente sea necesario realizar un rollback.
Próximos pasos
Knex es un excelente punto de partida si prefieres trabajar cerca de SQL, y una base sólida si más adelante deseas implementar un ORM encima. Lee la guía de SQL para perfeccionar las sentencias que genera el builder, y la guía de PostgreSQL para aprender sobre índices, transacciones y planes de consulta. Si prefieres un cliente tipado basado en esquemas, Prisma genera uno automáticamente, mientras que Drizzle ORM mantiene un estilo más cercano al builder con inferencia completa de TypeScript.