Query Builder

Knex.js

Knex.js is a SQL query builder, not a full ORM. It composes queries as JavaScript, binds every value safely, speaks several dialects and ships migrations and seeds alongside the queries.

beginner14 min readUpdated Sep 16, 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;
Released
2013
Kind
SQL query builder
Dialects
Postgres, MySQL, SQLite, MSSQL, Oracle
Language
JavaScript / TypeScript
Migrations
Built-in schema builder
Current line
3.x

Why it matters

What a query builder gives you

Composable SQL

Chain select, where, join and orderBy to build a statement piece by piece, then await it to run the query.

Schema builder and migrations

Create and alter tables in JavaScript, version every change as a migration, and seed local data with the same tool.

Several dialects, one API

The same builder runs against Postgres, MySQL and SQLite, with a driver swap and only occasional dialect-specific code.

The big picture

Three ideas behind Knex

Compose SQL as JavaScript, bind values safely, and use the same builder across several database dialects.

The builder

Compose

knex('users') starts a query, and each chained method adds a clause until you await the result.

The dialect

Translate

Knex turns the builder chain into parameterised SQL for the client you configured, quoting identifiers per dialect.

The pool

Connect

A connection pool sits underneath every query, and the knex instance owns its lifecycle.

Data model

The users table, defined once

Defined in a migration with the schema builder, so the same JavaScript creates the table on every environment.

The users tableKnex migration
  • idincrementsAuto-incrementing integer primary key
  • emailstring(255)Not nullable and unique, enforced by the database
  • display_namestring(120)The public name shown in the interface
  • created_attimestampDefaults to the database's now() on insert

Defined in a migration with the schema builder, so the same JavaScript creates the table on every environment.

A short history

A builder that outlasted its ORM

  1. 2013

    Knex is released

    A query builder for Node.js arrives, bundling migrations and a schema builder with the query API.

    13
  2. 2016

    Objection.js builds on Knex

    An ORM layer appears on top of the builder, proving the two can be combined rather than replaced.

    16
  3. 2018

    Async/await everywhere

    Knex queries become thenables, so the builder fits naturally into modern JavaScript.

    18
  4. 2020

    Knex 1.0 and TypeScript

    A stable major line arrives with improved typings and continued dialect coverage.

    20
  5. 2023

    Knex 3.x

    The builder keeps a small, stable surface while remaining a dependency of larger ORMs.

    23

The complete guide

Knex.js: Everything you need to know

What is Knex.js?

Knex.js is a SQL query builder for Node.js. It is deliberately not an ORM. It does not map rows to classes, does not track object state and does not load relations on its own. What it does is let you build SQL statements as composable JavaScript objects, bind every value safely, and run them against Postgres, MySQL, SQLite, MSSQL or Oracle.

That restraint is the reason it has lasted. Knex appeared in 2013 and became the foundation that Objection.js and several other libraries build on. If you want the ergonomics of an ORM, you can add one on top; if you want to stay close to SQL, Knex is already the right level.

The mental model is small: start with a table name, chain methods to describe the statement, then await it to execute. Every chain eventually becomes one parameterised query.

Why use a query builder at all?

Writing SQL as template strings seems fine until a query needs to change shape. A search endpoint with optional filters, a sort that depends on user input and a pagination clause is painful to assemble by concatenation, and dangerous if any value is interpolated directly.

A builder solves both problems. Conditions append cleanly, values become bound parameters and identifiers are quoted for the target dialect.

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);

Notice that search is passed as an argument to where, not spliced into a string. Knex sends it to the driver as a placeholder, so it can never change the structure of the statement.

The other benefit is portability. The same builder chain runs on Postgres and MySQL with only a configuration change, which is useful for local SQLite development and tests.

Installing and the knexfile

Install Knex and the driver for your database. The driver is a separate package, because Knex does not bundle database clients.

pnpm add knex pg
pnpm add -D @types/pg

Configuration lives in a knexfile, one entry per environment. The CLI reads it automatically, and your application imports the same object.

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;

Create a single shared instance and import it everywhere, rather than calling knex() in each module. One instance owns one connection pool, and extra instances multiply connections against the database.

import knex from "knex";
import config from "../knexfile";

export const db = knex(config.development);

Use TypeScript’s satisfies or a typed config object so the editor catches a misspelled option, and keep secrets in the environment rather than the file.

Migrations with the schema builder

Migrations are how the schema changes over time. Each file has an up that applies the change and a down that reverses it.

npx knex migrate:make create_users
npx knex migrate:latest
npx knex migrate:rollback
npx knex migrate:list

The generated file is ordinary TypeScript. The schema builder describes tables with a callback that receives a table object.

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 records applied migrations in a knex_migrations table, so each environment knows exactly which files it has run. Always write a real down; even if you rarely roll back, it documents how to undo the change and makes testing migrations possible.

alterTable changes an existing table. Some changes, such as adding a notNullable column to a table with rows, need a default or a backfill step. Split those into two migrations: one to add the column and backfill, another to add the constraint.

Seeds for local data

Seeds populate a database with known rows for development and tests. They are separate from migrations because they represent data, not structure.

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" },
  ]);
}

Run them with npx knex seed:run. Seeds should be idempotent where possible, which usually means clearing the table or using an upsert before inserting, so running them twice does not fail or duplicate data.

Building queries

A query starts with a table name. From there, methods chain until the statement is awaited.

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 accepts several forms: a column and value, a column, operator and value, or an object of equalities. whereIn, whereNot, whereNull, whereBetween and whereExists cover the rest of the common predicates. Grouped conditions use a callback so the parentheses land in the right place.

db("posts")
  .where("published", true)
  .andWhere((qb) => {
    qb.where("title", "ilike", `%${term}%`).orWhere("body", "ilike", `%${term}%`);
  });

Use .first() when you expect a single row, and pluck when you want a flat array of one column.

const user = await db("users").where({ email }).first();
const emails = await db("users").pluck("email");

Joins, aggregates and group by

Joins read the same way they do in SQL: a table, then the column pair that links it.

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 is an inner join, leftJoin keeps unmatched rows from the first table, and rightJoin and fullOuterJoin are available on dialects that support them. The join condition can also be a callback when it needs more than one clause.

Aggregates use .count(), .sum(), .avg(), .min() and .max(). Two details catch people out. First, any non-aggregated column in the select must appear in groupBy. Second, on Postgres the count and sum values come back as strings to avoid integer overflow, so cast or parse them when you need numbers.

const { count } = await db("users").count("* as count").first();
const total = Number(count);

The returning clause

Inserting a row usually means wanting the generated primary key or the server-side defaults. On Postgres and MSSQL, .returning() asks the database to send the row back.

const [user] = await db("users")
  .insert({ email, display_name: displayName })
  .returning(["id", "email", "created_at"]);

Updates and deletes can return rows too:

const updated = await db("posts")
  .where({ id })
  .update({ title, updated_at: db.fn.now() })
  .returning("*");

MySQL and older SQLite do not support RETURNING. There, the insert call resolves to the new id, and you issue a follow-up select. Knowing which dialect you target matters, because this is one of the places where the portability promise has limits.

Upserts and conflict handling

A common pattern is “insert this row, but update it if it already exists”. Knex expresses it with onConflict, which maps to ON CONFLICT on Postgres and SQLite and to ON DUPLICATE KEY UPDATE on 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 takes the unique column or columns that define the conflict. merge updates the listed columns from the new row, while ignore skips the insert entirely. This is the safe way to make a sync or webhook handler idempotent, because the database resolves the race between two concurrent writers rather than your application code.

Transactions

A transaction groups statements so they commit or roll back together. db.transaction passes a transaction object to the callback, and every statement inside must use it.

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);
});

Throwing rolls the transaction back and rejects the promise. Returning commits it. The most common mistake is mixing trx and db inside the same block: queries issued on db use a different pooled connection and run outside the transaction, so they are not rolled back.

For manual control, db.transaction() without a callback returns a transaction object with commit and rollback methods. Prefer the callback form; it is harder to leak a connection by forgetting to commit.

Raw queries when you need them

The builder cannot express everything, and it does not try to. knex.raw runs a SQL string with bound values.

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;

Always use ? placeholders and pass the values in the array. Interpolating them into the string reintroduces exactly the injection risk the builder exists to remove. You can embed a raw fragment inside a builder chain with whereRaw or select(db.raw(...)) when only part of the query needs hand-written SQL.

Window functions, recursive CTEs, COPY and dialect-specific operators are all good reasons to drop to raw. A ranking query, for example, is clearer written out than assembled from builder fragments:

const { rows } = await db.raw(
  `select email, score,
          row_number() over (order by score desc) as rank
   from leaderboard
   where season = ?`,
  [season],
);

Keep such fragments small and commented, and prefer the builder for everything around them.

Connection pooling

Every query runs through a pool of connections managed by tarn.js. The default is a minimum of two and a maximum of ten, which is reasonable for a single process but needs tuning in production.

const db = knex({
  client: "pg",
  connection: process.env.DATABASE_URL,
  pool: { min: 2, max: 10, acquireTimeoutMillis: 30_000 },
});

Size the pool from the database’s max_connections, divided across all application instances. A pool that is too large is as harmful as one that is too small: too many connections exhaust the server’s memory and process table. Put a pooler such as PgBouncer in front when you run many instances or serverless functions.

Call await db.destroy() when the process shuts down. Without it, open connections keep the event loop alive and a graceful shutdown hangs.

Pairing Knex with Objection.js

Knex returns plain rows, so if you want models, relations and lifecycle hooks, add Objection.js. It is built directly on Knex and reuses the same connection.

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 });

This layering is the pragmatic answer for teams that want an ORM but dislike heavy abstraction: Knex handles SQL and migrations, Objection adds the object model, and you can drop back to Knex at any point for a query that the model layer does not fit.

You still write SQL

The most important thing to internalise about Knex is that it does not hide SQL. It reorders it, parameterises it and quotes it, but the statement it produces is exactly what you would have written.

That has two consequences. The first is good: reading a Knex chain tells you the query, and debugging means printing .toSQL() and reading the plan, not guessing at generated SQL.

The second is a responsibility: a builder will not save you from a missing index, a SELECT * over a wide table or an accidental cross join. You still design the schema, add the indexes and check EXPLAIN ANALYZE. Knex removes string plumbing, not the need to understand the database.

Best practices

  • Create one Knex instance and import it; never call knex() per module.
  • Bind every value as a parameter and use ? placeholders in raw queries.
  • Version every schema change as a migration with a real down method.
  • Split risky changes into separate migrations: add and backfill first, constrain later.
  • Use .returning() where the dialect supports it, and fall back to a follow-up select otherwise.
  • Always use the transaction object inside db.transaction, never the top-level instance.
  • Size the connection pool from the database limit and call db.destroy() on shutdown.
  • Cast Postgres count and sum results to numbers before doing arithmetic.
  • Print .toSQL() when a query surprises you, and confirm performance with EXPLAIN ANALYZE.

Common mistakes

  • Mixing trx and db in one transaction, so part of the work escapes the rollback.
  • Interpolating values into knex.raw strings instead of using placeholders.
  • Forgetting groupBy for non-aggregated columns and getting a SQL error.
  • Treating the string returned by count as a number and producing "10" + 1.
  • Assuming .returning() works identically on MySQL and SQLite.
  • Leaving a pool unbounded or larger than the database can handle.
  • Creating a new Knex instance per request and exhausting connections.
  • Editing a production schema by hand and letting environments drift.
  • Ignoring the down method until a rollback is actually needed.

Where to go next

Knex is a good place to stop if you like working close to SQL, and a good foundation if you later want an ORM on top. Read the SQL guide to sharpen the statements the builder produces, and the PostgreSQL guide for indexes, transactions and query plans. If you would rather have a typed schema-first client, Prisma generates one for you, while Drizzle ORM stays closer to the builder style with full TypeScript inference.

In practice

Migrations, queries, joins, transactions

The four things you do most with Knex, from creating the table to changing it atomically.

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");
}

A composable builder vs string concatenation

Building SQL by hand with template strings invites injection and quoting bugs. The builder binds values and escapes identifiers for you.

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

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

const rows = await query.orderBy("created_at", "desc");
Avoid
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);

Migrations vs changing the schema by hand

A migration is a versioned, reversible file that every environment runs in the same order. Hand-edited schemas drift and cannot be replayed.

Prefer
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");
  });
}
Avoid
-- Run once in a terminal against production,
-- then forgotten and never applied to staging.
ALTER TABLE users ADD COLUMN email_verified boolean;

Trade-offs

Is a query builder the right layer for you?

Knex sits between raw SQL and a full ORM. That position is a strength for some teams and extra ceremony for others.

Strengths

  • You keep SQL in view

    The chain reads like the statement it produces, so there is no hidden query generation and no magic to debug.

  • Safe by construction

    Values are always bound as parameters, identifiers are quoted per dialect, and dynamic filters compose without injection risk.

  • Migrations and seeds included

    The schema builder, migrations and seeds ship in the same package, so you do not need a second tool to manage the database.

Trade-offs

  • No models or relations

    Knex returns plain rows. You write your own mapping, and if you want relationships loaded for you, you need Objection.js or an ORM on top.

  • Typing is manual

    Rows are loosely typed by default. You annotate result shapes yourself or add a typed layer, which takes discipline as the schema grows.

  • Dialect differences leak

    The API is portable, but SQL is not. JSON operators, upserts and returning behave differently, so test on the database you deploy.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning Knex.js?

Our interactive tutorial walks you through Knex.js step by step — with quizzes and real code you can run in the browser.