Query Builders: Knex.js
In the previous article, you wrote raw SQL strings inside TypeScript. It works, but it has pain points: typos in column names aren't caught until runtime, building dynamic queries (optional filters, variable sort order) gets messy, and there's no built-in way to version your schema changes.
Knex.js is a query builder — a layer above the raw pg driver that lets you compose SQL using a JavaScript API. It generates the same parameterized SQL pg would run, but provides:
- A chainable API for composing queries
- A migration system to version schema changes
- A seed system to populate development data
- Multi-database support (same API works for PostgreSQL, MySQL, SQLite)
Knex is not an ORM. It doesn't define models, doesn't manage relationships, and doesn't hide the fact that you're working with SQL. That transparency is a feature — you stay in control of what queries run.
Quick Reference
Install:
npm install knex pg
npm install --save-dev @types/knex
npx knex init # generate knexfile.ts
Query:
const products = await knex("products")
.select("id", "name", "price")
.where("category", "Electronics")
.where("price", "<", 100)
.orderBy("price", "asc");
Insert with returning:
const [product] = await knex("products")
.insert({
name: "New Item",
price: 49.99,
category: "Accessories",
stock_quantity: 10,
})
.returning("*");
Migration:
npx knex migrate:make create_products_table
npx knex migrate:latest
npx knex migrate:rollback
Gotchas:
- ⚠️ Knex is NOT an ORM — it doesn't know about your table relationships
- ⚠️
.where('column', value)is an equality check;.where('column', '>', value)is a comparison - ⚠️ Always
awaitKnex queries — they return promises, not results - ⚠️
knex.raw()still requires parameterized input:knex.raw('WHERE id = ?', [id])
Version Information
Tested with:
knex: ^3.1.0pg: ^8.11.0- Node.js: v18.x, v20.x, v22.x
- TypeScript: 5.x
Last verified: May 2025
What You Need to Know First
Required reading:
- Connecting Node.js to PostgreSQL: node-postgres (pg) — Knex uses
pgunder the hood; understanding the driver helps you understand what Knex generates - Schema Design: Tables, Types, and Constraints — Knex migrations create the same schema you've been writing manually
What We'll Cover in This Article
By the end of this guide, you'll understand:
- How to install and configure Knex with PostgreSQL
- How to write CRUD queries using the Knex query builder API
- How to build dynamic queries with optional conditions
- How to create and run database migrations
- How to write seed files for development data
- How to implement transactions with Knex
- When Knex is the right tool — and when to use raw SQL instead
What We'll Explain Along the Way
We'll introduce these concepts with full explanations:
- What a migration is and why you need schema versioning
- How Knex tracks which migrations have been run
- The
knexfile.tsconfiguration and environment-specific settings - When to use
knex.raw()for complex queries
Part 1: Installation and Configuration
npm install knex pg
npm install --save-dev @types/pg
Note: Knex ships with its own TypeScript types (@types/knex is not needed for Knex v3+).
knexfile.ts — The Configuration File
Create knexfile.ts in your project root:
// knexfile.ts
import type { Knex } from "knex";
const config: { [key: string]: Knex.Config } = {
development: {
client: "postgresql",
connection: process.env.DATABASE_URL,
pool: {
min: 2,
max: 10,
},
migrations: {
directory: "./migrations",
extension: "ts",
},
seeds: {
directory: "./seeds",
extension: "ts",
},
},
production: {
client: "postgresql",
connection: {
connectionString: process.env.DATABASE_URL,
ssl: { rejectUnauthorized: false }, // required by most cloud PostgreSQL providers
},
pool: {
min: 2,
max: 20,
},
migrations: {
directory: "./migrations",
},
},
};
export default config;
The Knex Instance — Your Database Client
Create a shared Knex instance, just like you created a Pool in the previous article:
// src/db.ts
import "dotenv/config";
import knex from "knex";
import config from "../knexfile.js";
const env = process.env.NODE_ENV ?? "development";
export const db = knex(config[env]);
Import db wherever you need to run queries. Create it once, use it everywhere.
Part 2: Querying with Knex
The Knex API is chainable — you start with a table, add conditions, and eventually await the result:
SELECT
import { db } from "./db.js";
// Select all columns
const products = await db("products");
// Select specific columns
const products = await db("products").select("id", "name", "price");
// With WHERE
const electronics = await db("products")
.select("id", "name", "price")
.where("category", "Electronics"); // equality: WHERE category = 'Electronics'
// Multiple conditions (AND by default)
const affordableElectronics = await db("products")
.select("id", "name", "price")
.where("category", "Electronics")
.where("price", "<", 100) // range: WHERE price < 100
.where("stock_quantity", ">", 0);
// OR conditions
const results = await db("products")
.where("category", "Electronics")
.orWhere("category", "Accessories");
// ORDER BY
const sorted = await db("products")
.select("id", "name", "price")
.orderBy("price", "asc");
// LIMIT and OFFSET
const page2 = await db("products")
.select("*")
.orderBy("name")
.limit(10)
.offset(10);
// Single row
const product = await db("products").where("id", 3).first(); // Returns first row object, or undefined — not an array
What Knex Actually Generates
Knex isn't magic — it generates SQL you could have written yourself. You can log the generated SQL:
// Log the SQL before executing
const query = db("products")
.select("id", "name", "price")
.where("category", "Electronics")
.where("price", "<", 100);
console.log(query.toSQL().toNative());
// {
// sql: 'select "id", "name", "price" from "products"
// where "category" = $1 and "price" < $2',
// bindings: ['Electronics', 100]
// }
Notice: Knex generates a parameterized query. The values are always in bindings, never concatenated into the SQL string.
JOIN
// INNER JOIN
const orderSummaries = await db("orders")
.select("users.name AS customer", "orders.id AS order_id", "orders.status")
.join("users", "orders.user_id", "=", "users.id")
.orderBy("orders.id");
// LEFT JOIN
const usersWithOrders = await db("users")
.select("users.name", db.raw("COUNT(orders.id) AS order_count"))
.leftJoin("orders", "orders.user_id", "users.id")
.groupBy("users.id", "users.name")
.orderBy("order_count", "desc");
Dynamic Queries — The Knex Advantage
This is where Knex pulls ahead of raw SQL strings. Building a query with optional filters in raw SQL requires messy string manipulation:
// ✅ Dynamic query with Knex — clean and readable
async function searchProducts(filters: {
category?: string;
minPrice?: number;
maxPrice?: number;
inStockOnly?: boolean;
sortBy?: "price" | "name";
sortDir?: "asc" | "desc";
page?: number;
pageSize?: number;
}) {
const {
category,
minPrice,
maxPrice,
inStockOnly,
sortBy = "name",
sortDir = "asc",
page = 1,
pageSize = 20,
} = filters;
const query = db("products").select(
"id",
"name",
"price",
"stock_quantity",
"category",
);
// Each condition is only added if the filter was provided
if (category) {
query.where("category", category);
}
if (minPrice !== undefined) {
query.where("price", ">=", minPrice);
}
if (maxPrice !== undefined) {
query.where("price", "<=", maxPrice);
}
if (inStockOnly) {
query.where("stock_quantity", ">", 0);
}
query.orderBy(sortBy, sortDir);
query.limit(pageSize).offset((page - 1) * pageSize);
return query; // await happens here
}
// Usage
const results = await searchProducts({
category: "Electronics",
inStockOnly: true,
});
Doing this with raw SQL would require building a conditions array and joining it — more code, more error-prone. Knex's chainable API handles it cleanly.
INSERT
// Single insert — returns number of affected rows by default in Knex
await db("products").insert({
name: "Standing Desk Mat",
description: "Anti-fatigue mat, 90x60cm",
price: 79.99,
stock_quantity: 50,
category: "Accessories",
});
// With RETURNING (PostgreSQL-specific)
const [newProduct] = await db("products")
.insert({
name: "Standing Desk Mat",
price: 79.99,
category: "Accessories",
stock_quantity: 50,
})
.returning("*");
console.log(newProduct.id); // The generated ID
// Multi-row insert
await db("products").insert([
{
name: "Item A",
price: 19.99,
category: "Accessories",
stock_quantity: 100,
},
{ name: "Item B", price: 29.99, category: "Electronics", stock_quantity: 50 },
]);
UPDATE
// Update with returning
const [updated] = await db("products")
.where("id", 3)
.update({ price: 44.99 })
.returning(["id", "name", "price"]);
// Update multiple columns
await db("products")
.where("category", "Electronics")
.update({
price: db.raw("price * 0.9"), // 10% discount
});
DELETE
const deletedCount = await db("products").where("stock_quantity", 0).delete();
console.log(`Deleted ${deletedCount} out-of-stock products`);
Part 3: Migrations — Versioning Your Schema
A migration is a versioned, reversible schema change. Instead of manually running ALTER TABLE commands (which don't get tracked anywhere), migrations are committed to source control and run in order on every environment.
Knex tracks which migrations have been run in a knex_migrations table it creates automatically.
Creating a Migration
npx knex migrate:make create_shopflow_schema --knexfile=knexfile.ts
This creates a timestamped file like migrations/20240315143022_create_shopflow_schema.ts.
Each migration has an up function (apply the change) and a down function (reverse it):
// migrations/20240315143022_create_shopflow_schema.ts
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Create users
await knex.schema.createTable("users", (table) => {
table.increments("id").primary(); // SERIAL PRIMARY KEY
table.text("name").notNullable();
table.text("email").notNullable().unique();
table
.timestamp("created_at", { useTz: true })
.notNullable()
.defaultTo(knex.fn.now());
});
// Create products
await knex.schema.createTable("products", (table) => {
table.increments("id").primary();
table.text("name").notNullable();
table.text("description"); // nullable — no .notNullable()
table.decimal("price", 10, 2).notNullable().checkPositive();
table.integer("stock_quantity").notNullable().defaultTo(0);
table.text("category").notNullable();
table
.timestamp("created_at", { useTz: true })
.notNullable()
.defaultTo(knex.fn.now());
});
// Create orders
await knex.schema.createTable("orders", (table) => {
table.increments("id").primary();
table
.integer("user_id")
.notNullable()
.references("id")
.inTable("users")
.onDelete("RESTRICT");
table.text("status").notNullable().defaultTo("pending");
table
.timestamp("created_at", { useTz: true })
.notNullable()
.defaultTo(knex.fn.now());
});
// Create order_items
await knex.schema.createTable("order_items", (table) => {
table.increments("id").primary();
table
.integer("order_id")
.notNullable()
.references("id")
.inTable("orders")
.onDelete("CASCADE");
table
.integer("product_id")
.notNullable()
.references("id")
.inTable("products")
.onDelete("RESTRICT");
table.integer("quantity").notNullable();
table.decimal("price_at_purchase", 10, 2).notNullable();
table.unique(["order_id", "product_id"]);
});
// Add indexes
await knex.schema.table("orders", (table) => {
table.index("user_id", "idx_orders_user_id");
table.index("status", "idx_orders_status");
});
await knex.schema.table("order_items", (table) => {
table.index("order_id", "idx_order_items_order_id");
table.index("product_id", "idx_order_items_product_id");
});
}
export async function down(knex: Knex): Promise<void> {
// Drop in reverse order (children before parents)
await knex.schema.dropTableIfExists("order_items");
await knex.schema.dropTableIfExists("orders");
await knex.schema.dropTableIfExists("products");
await knex.schema.dropTableIfExists("users");
}
Running Migrations
# Apply all pending migrations
npx knex migrate:latest --knexfile=knexfile.ts
# Roll back the most recent migration batch
npx knex migrate:rollback --knexfile=knexfile.ts
# Roll back all migrations
npx knex migrate:rollback --all --knexfile=knexfile.ts
# Check migration status
npx knex migrate:status --knexfile=knexfile.ts
Adding Columns — Incremental Migrations
Schema changes after the initial migration are separate files, never edits to the original migration:
npx knex migrate:make add_sku_to_products --knexfile=knexfile.ts
// migrations/20240320091500_add_sku_to_products.ts
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("products", (table) => {
table.text("sku").unique().nullable();
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable("products", (table) => {
table.dropColumn("sku");
});
}
Part 4: Seeds — Development Data
Seeds populate your database with sample data for development and testing. Unlike migrations, seeds are idempotent — you can run them multiple times without duplicating data (when written correctly).
npx knex seed:make 001_users --knexfile=knexfile.ts
// seeds/001_users.ts
import type { Knex } from "knex";
export async function seed(knex: Knex): Promise<void> {
// Delete existing data first (in child-to-parent order)
await knex("order_items").del();
await knex("orders").del();
await knex("products").del();
await knex("users").del();
// Insert users
await knex("users").insert([
{
name: "Aisha Mwangi",
email: "aisha@example.com",
created_at: "2024-01-15 09:23:00+00",
},
{
name: "Marco Rossi",
email: "marco@example.com",
created_at: "2024-01-16 14:05:00+00",
},
{
name: "Priya Sharma",
email: "priya@example.com",
created_at: "2024-01-17 11:30:00+00",
},
{
name: "Carlos Rivera",
email: "carlos@example.com",
created_at: "2024-02-01 08:00:00+00",
},
{
name: "Lin Wei",
email: "lin@example.com",
created_at: "2024-02-14 16:45:00+00",
},
]);
// Insert products
await knex("products").insert([
{
name: "Wireless Keyboard",
price: 89.99,
stock_quantity: 45,
category: "Electronics",
},
{
name: "Mechanical Mouse",
price: 64.99,
stock_quantity: 30,
category: "Electronics",
},
{
name: "Laptop Stand",
price: 49.99,
stock_quantity: 0,
category: "Accessories",
},
{
name: "USB-C Hub",
price: 39.99,
stock_quantity: 120,
category: "Electronics",
},
]);
}
# Run all seeds
npx knex seed:run --knexfile=knexfile.ts
# Run specific seed
npx knex seed:run --specific=001_users.ts --knexfile=knexfile.ts
Part 5: Transactions with Knex
async function placeOrder(
userId: number,
items: Array<{ productId: number; quantity: number; price: number }>,
): Promise<number> {
return db.transaction(async (trx) => {
// trx is a transaction-scoped Knex instance
// All queries on trx are part of the same transaction
// Insert order
const [order] = await trx("orders")
.insert({ user_id: userId, status: "pending" })
.returning("id");
const orderId = order.id;
// Insert items and update stock
for (const item of items) {
await trx("order_items").insert({
order_id: orderId,
product_id: item.productId,
quantity: item.quantity,
price_at_purchase: item.price,
});
const updated = await trx("products")
.where("id", item.productId)
.where("stock_quantity", ">=", item.quantity)
.decrement("stock_quantity", item.quantity);
if (updated === 0) {
// Throwing inside db.transaction() automatically triggers ROLLBACK
throw new Error(`Insufficient stock for product ${item.productId}`);
}
}
// If we reach here without throwing, Knex commits the transaction
return orderId;
});
}
db.transaction(async (trx) => { ... }) handles BEGIN, COMMIT, and ROLLBACK automatically:
- If the callback resolves successfully →
COMMIT - If the callback throws →
ROLLBACK
You don't need try/catch inside the callback for the transaction itself — throwing is the signal to roll back.
Part 6: When to Use Raw SQL
Knex's API covers most queries. But complex SQL — window functions, CTEs, complex aggregations — is sometimes easier to write as raw SQL:
// knex.raw() for complex queries
const result = await db.raw(`
WITH order_totals AS (
SELECT
order_id,
SUM(price_at_purchase * quantity) AS total
FROM order_items
GROUP BY order_id
)
SELECT
u.name,
COUNT(o.id) AS order_count,
SUM(ot.total) AS lifetime_value
FROM users u
JOIN orders o ON o.user_id = u.id
JOIN order_totals ot ON ot.order_id = o.id
GROUP BY u.id, u.name
ORDER BY lifetime_value DESC
`);
const rows = result.rows;
// knex.raw() with parameters (use ? placeholders, not $1 — Knex handles the mapping)
const { rows } = await db.raw(
"SELECT * FROM products WHERE category = ? AND price < ?",
["Electronics", 100],
);
knex.raw() still goes through pg — it's still parameterized, still safe. The ? placeholders are Knex's syntax; they get mapped to $1, $2, etc. before hitting PostgreSQL.
Common Misconceptions
❌ Misconception: Knex is an ORM
Reality: Knex is a query builder and migration tool. It has no concept of models, relationships, or the active record/data mapper patterns that define ORMs. It generates SQL and gives you the raw row objects back.
Practical implication: If you want to fetch a user's orders from Knex, you write a query. There's no user.orders() method — you join, or make a second query, manually.
❌ Misconception: .where('column', value) handles all comparison types
Reality: .where('column', value) generates WHERE column = value (equality only). For other comparisons:
.where('price', '>', 50) // WHERE price > 50
.where('price', '>=', 50) // WHERE price >= 50
.whereBetween('price', [40, 80]) // WHERE price BETWEEN 40 AND 80
.whereIn('category', ['Electronics', 'Accessories']) // WHERE category IN (...)
.whereNull('description') // WHERE description IS NULL
.whereNotNull('description') // WHERE description IS NOT NULL
❌ Misconception: Knex migrations auto-detect schema differences
Reality: Knex migrations are explicit — you write the up and down functions manually. Knex doesn't compare your current schema to the desired state and generate SQL automatically (that's closer to what Prisma does). You write every createTable, addColumn, and createIndex yourself.
Troubleshooting Common Issues
Problem: Migration file already exists
Symptoms: npx knex migrate:make fails with this error.
Cause: Two migrations were created in the same second (timestamp collision). Rare, but possible.
Solution: Add a suffix to distinguish them, or wait a second between migrate:make calls.
Problem: Migration ran but down doesn't restore the original state
Symptoms: After migrate:rollback, the database isn't what you expected.
Cause: The down function is incomplete or out of order.
Prevention:
// Always test rollback during development
npx knex migrate:rollback && npx knex migrate:latest
Problem: knex.raw() query returns unexpected results
Symptoms: The raw query looks correct but returns wrong data.
Cause: Knex wraps raw() results differently from regular queries — the result is the full pg result object.
// Access rows correctly from raw()
const result = await db.raw("SELECT * FROM products");
const rows = result.rows; // NOT result.rows[0]
Check Your Understanding
Quick Quiz
-
What's the difference between Knex and an ORM like Prisma?
Show Answer
Knex is a query builder — it helps you write SQL using a JavaScript API and generates parameterized queries, but it has no knowledge of your table relationships, doesn't define models, and returns raw row objects. An ORM like Prisma defines models with relationships, generates a type-safe client, and handles relation loading (eager loading, lazy loading) automatically. Knex gives you more control; Prisma gives you more abstraction.
-
Why should you never edit an existing migration file once it's been run?
Show Answer
Knex tracks which migrations have been run by their filename and checksum. Editing a migration that's already run means other developers (and production) won't re-run it — they'll have a different schema than what the file describes. Always create a new migration for any schema change, even small ones.
-
How does
db.transaction()handle rollback?Show Answer
If the async callback passed to
db.transaction()throws an error (or returns a rejected promise), Knex automatically runsROLLBACKbefore re-throwing the error. If the callback resolves successfully, Knex runsCOMMIT. You don't need to manually callROLLBACK— just throw an error to abort.
Hands-On Challenge
Task: Write a Knex migration and a query function.
Migration: Add a discount_percent column to products (nullable, DECIMAL(5,2), with a CHECK that it's between 0 and 100).
Query function: getDiscountedProducts() — returns all products with a non-null discount_percent, including a computed discounted_price column, sorted by discount percentage descending.
Show Solution
// migrations/TIMESTAMP_add_discount_to_products.ts
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("products", (table) => {
table.decimal("discount_percent", 5, 2).nullable();
});
// CHECK constraint via raw (Knex schema builder doesn't expose named checks easily)
await knex.raw(`
ALTER TABLE products
ADD CONSTRAINT check_discount_range
CHECK (discount_percent IS NULL OR (discount_percent >= 0 AND discount_percent <= 100))
`);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable("products", (table) => {
table.dropColumn("discount_percent");
});
}
// Query function
interface DiscountedProduct {
id: number;
name: string;
price: string;
discount_percent: string;
discounted_price: string;
}
async function getDiscountedProducts(): Promise<DiscountedProduct[]> {
const rows = await db("products")
.select(
"id",
"name",
"price",
"discount_percent",
db.raw(
"ROUND(price * (1 - discount_percent / 100), 2) AS discounted_price",
),
)
.whereNotNull("discount_percent")
.orderBy("discount_percent", "desc");
return rows as DiscountedProduct[];
}
Summary: Key Takeaways
- Knex is a query builder and migration tool — not an ORM; it generates SQL but doesn't know about relationships
- Install
knexandpg; configure viaknexfile.tswith environment-specific settings - The chainable API builds queries:
db('table').select(...).where(...).orderBy(...) .toSQL().toNative()shows the generated SQL — useful for debugging- Knex excels at dynamic queries — adding conditions based on optional input is clean and readable
RETURNINGworks oninsert().returning(...),update().returning(...), anddelete().returning(...)- Migrations version your schema changes: create with
migrate:make, apply withmigrate:latest, reverse withmigrate:rollback - Never edit an existing migration — always create a new one for schema changes
- Seeds populate development data; run with
seed:run db.transaction(async (trx) => { ... })— throw to rollback, return to commit- Use
knex.raw()for complex SQL that the builder can't express cleanly
What's Next?
You've seen raw pg (full control, verbose) and Knex (SQL-transparent, with migrations). The next step up the abstraction ladder is a full ORM.
The next step is Drizzle ORM: SQL Without the Magic — a TypeScript-first ORM that keeps SQL front-and-center while giving you end-to-end type safety, schema ownership, and a migration workflow that generates real SQL files you can inspect. We'll rebuild the ShopFlow data layer with Drizzle and compare it directly to what you just built with Knex.