Query Builder

Knex.js

Knex.js es un constructor de consultas SQL, no un ORM completo. Compone consultas como JavaScript, vincula cada valor de forma segura, soporta varios dialectos e incluye migraciones y seeds junto con las consultas.

beginner14 min readUpdated 16 sept 2026
knexfile.ts
ts
// knexfile.ts
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;
Lanzado
2013
Tipo
SQL query builder
Dialectos
Postgres, MySQL, SQLite, MSSQL, Oracle
Lenguaje
JavaScript / TypeScript
Migraciones
Constructor de esquemas integrado
Versión actual
3.x

Por que importa

Lo que te ofrece un query builder

SQL Componible

Encadena select, where, join y orderBy para construir una sentencia pieza por pieza, y luego usa await para ejecutar la consulta.

Constructor de esquemas y migraciones

Crea y altera tablas en JavaScript, versiona cada cambio como una migración y carga datos locales con seeds usando la misma herramienta.

Varios dialectos, una sola API

El mismo constructor funciona con Postgres, MySQL y SQLite, cambiando solo el driver y con código específico del dialecto solo ocasionalmente.

La imagen completa

Tres ideas detrás de Knex

Componer SQL como JavaScript, vincular valores de forma segura y utilizar el mismo constructor en varios dialectos de bases de datos.

El constructor

Componer

knex('users') inicia una consulta, y cada método encadenado añade una cláusula hasta que esperas el resultado con await.

El dialecto

Traducir

Knex convierte la cadena del constructor en SQL parametrizado para el cliente configurado, aplicando las comillas a los identificadores según el dialecto.

El pool

Conectar

Un pool de conexiones reside debajo de cada consulta, y la instancia de knex gestiona su ciclo de vida.

Modelo de datos

La tabla de usuarios, definida una sola vez

Definida en una migración con el constructor de esquemas, para que el mismo JavaScript cree la tabla en cualquier entorno.

La tabla de usuariosKnex migration
  • idincrementsClave primaria entera con auto-incremento
  • emailstring(255)No nulo y único, impuesto por la base de datos
  • display_namestring(120)El nombre público mostrado en la interfaz
  • created_attimestampPor defecto usa el now() de la base de datos al insertar

Definida en una migración con el constructor de esquemas, para que el mismo JavaScript cree la tabla en cualquier entorno.

Una breve historia

Un constructor que sobrevivió a su ORM

  1. 2013

    Lanzamiento de Knex

    Llega un query builder para Node.js que integra migraciones y un constructor de esquemas con la API de consultas.

    13
  2. 2016

    Objection.js se construye sobre Knex

    Aparece una capa de ORM sobre el constructor, demostrando que ambos pueden combinarse en lugar de reemplazarse.

    16
  3. 2018

    Async/await en todas partes

    Las consultas de Knex se vuelven thenables, por lo que el constructor se integra naturalmente en el JavaScript moderno.

    18
  4. 2020

    Knex 1.0 y TypeScript

    Llega una línea mayor estable con tipados mejorados y cobertura continua de dialectos.

    20
  5. 2023

    Knex 3.x

    El constructor mantiene una superficie pequeña y estable mientras sigue siendo una dependencia de ORMs más grandes.

    23

La guia completa

Knex.js: Todo lo que necesitas saber

¿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 down real.
  • 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 count y sum a números antes de realizar operaciones aritméticas.
  • Imprime .toSQL() cuando una consulta te sorprenda y confirma el rendimiento con EXPLAIN ANALYZE.

Errores comunes

  • Mezclar trx y db en una sola transacción, provocando que parte del trabajo escape del rollback.
  • Interpolar valores en cadenas de knex.raw en lugar de utilizar placeholders.
  • Olvidar el groupBy para columnas no agregadas y provocar un error de SQL.
  • Tratar la cadena devuelta por count como 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 down hasta 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.

En la practica

Migraciones, consultas, joins, transacciones

Las cuatro cosas que más haces con Knex, desde crear la tabla hasta modificarla atómicamente.

migrations/20240101_create_users.ts
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");
}

Constructor componible vs concatenación de strings

Construir SQL a mano con template strings invita a errores de inyección y de comillas. El constructor vincula los valores y escapa los identificadores por ti.

Preferir
const query = db("users").select("id", "email");

if (search) {
  query.where("email", "ilike", `%${search}%`);
}

const rows = await query.orderBy("created_at", "desc");
Evitar
let sql = "select id, email from users";

if (search) {
  // The value is interpolated straight into the statement.
  sql += ` where email ilike '%${search}%'`;
}

const rows = await db.raw(sql);

Migraciones vs cambiar el esquema a mano

Una migración es un archivo versionado y reversible que cada entorno ejecuta en el mismo orden. Los esquemas editados a mano divergen y no se pueden reproducir.

Preferir
export async function up(knex: Knex) {
  await knex.schema.alterTable("users", (table) => {
    table.boolean("email_verified").notNullable().defaultTo(false);
  });
}

export async function down(knex: Knex) {
  await knex.schema.alterTable("users", (table) => {
    table.dropColumn("email_verified");
  });
}
Evitar
-- Run once in a terminal against production,
-- then forgotten and never applied to staging.
ALTER TABLE users ADD COLUMN email_verified boolean;

Compromisos

¿Es un query builder la capa adecuada para ti?

Knex se sitúa entre el SQL raw y un ORM completo. Esa posición es una fortaleza para algunos equipos y una ceremonia extra para otros.

Strengths

  • Mantienes el SQL a la vista

    La cadena se lee como la sentencia que produce, por lo que no hay generación de consultas oculta ni magia que depurar.

  • Seguro por construcción

    Los valores siempre se vinculan como parámetros, los identificadores se encierran entre comillas según el dialecto y los filtros dinámicos se componen sin riesgo de inyección.

  • Migraciones y seeds incluidos

    El constructor de esquemas, las migraciones y los seeds vienen en el mismo paquete, por lo que no necesitas una segunda herramienta para gestionar la base de datos.

Trade-offs

  • Sin modelos ni relaciones

    Knex devuelve filas simples. Tú escribes tu propio mapeo, y si quieres que las relaciones se carguen automáticamente, necesitas Objection.js o un ORM encima.

  • El tipado es manual

    Las filas tienen un tipado débil por defecto. Tú mismo anotas las formas de los resultados o añades una capa tipada, lo cual requiere disciplina a medida que el esquema crece.

  • Se filtran diferencias de dialecto

    La API es portable, pero el SQL no lo es. Los operadores JSON, los upserts y el returning se comportan de forma diferente, así que prueba en la base de datos donde despliegues.

Preguntas frecuentes

Preguntas frecuentes

Keep learning

Related topics from the roadmap.

$ comienza a aprender

Listo para aprender Knex.js?

Nuestro tutorial interactivo te guia a traves de Knex.js paso a paso — con quizzes y codigo real que puedes ejecutar en el navegador.