Knex.js Transactions: The trx Object Is the Whole Point
Knex wraps transactions in a trx object, but atomicity only holds if every query inside uses it. The failure mode, a funds-transfer example, and how to check your code.
24 Sept 2025, 08:46 UTC

A funds transfer is the classic case: debit one account, credit another, and if the credit fails the debit must not survive. Knex.js provides knex.transaction() for exactly this, and the wrapper itself is easy to use. What breaks in production is quieter — a single query written with knex() instead of trx() runs outside the transaction, on a different pooled connection, and nothing warns you.
This assumes Knex 2.x/3.x-era APIs on PostgreSQL or MySQL. Transaction options and dialect support have shifted across releases, so confirm details against the version in your lockfile.
Why knex() inside a transaction silently breaks atomicity
When you call knex.transaction(callback), Knex checks out one connection from the pool and hands you a transaction object, conventionally named trx. That object is bound to that connection, so trx('accounts') travels inside the same BEGIN ... COMMIT block.
The top-level knex instance knows nothing about this. It is a query builder backed by the pool, so knex('audit_log').insert(...) inside your callback checks out a second connection and runs as its own autocommit statement. It is not part of the transaction, it is not rolled back, and it does not error. You get a partial write that looks successful.
The same trap catches helpers: a repository function that closes over a module-level knex, a model method using the global connection, a logging insert. Passing trx down the call stack is what makes the transaction real.
Callback form vs. explicit commit
The callback form is the default choice: Knex commits when the returned promise resolves and rolls back when it rejects.
await knex.transaction(async (trx) => {
// every query here must use trx
});The explicit form hands you the transaction object and leaves commit and rollback to you:
const trx = await knex.transaction();
try {
await trx('accounts').where({ id: 1 }).update({ balance_cents: 100 });
await trx.commit();
} catch (err) {
await trx.rollback();
throw err;
}| Callback form | Explicit form | |
|---|---|---|
| Who commits | Knex, when the callback's promise resolves | You, via trx.commit() |
| On thrown error | Automatic rollback | You must call trx.rollback() |
| Main failure mode | A query not awaited, so the callback resolves early | A missing commit or rollback, leaving the connection held |
Both styles share one requirement: every query must be awaited or returned. Knex's query builder is thenable, so a query you start and do not await can still be in flight when the callback returns, and the commit may land before it finishes. In the explicit form, put rollback() in a catch and rethrow — swallowing the error leaves the caller believing the write succeeded.
A worked example: transfer with an insufficient-funds rollback
Run this in your application code, not the Knex CLI. It needs a configured knex instance and an accounts table with id and balance_cents columns. It is illustrative; verify it against your schema and installed version before relying on it.
async function transferFunds(knex, fromId, toId, amountCents) {
return knex.transaction(async (trx) => {
const from = await trx('accounts')
.where({ id: fromId })
.forUpdate() // dialect-dependent
.first();
if (!from) throw new Error(`account ${fromId} not found`);
if (from.balance_cents < amountCents) {
throw new Error('insufficient funds'); // rejects -> rollback
}
await trx('accounts')
.where({ id: fromId })
.decrement('balance_cents', amountCents);
await trx('accounts')
.where({ id: toId })
.increment('balance_cents', amountCents);
return { fromId, toId, amountCents };
});
}Two things do the work. Every statement uses trx, so the debit and credit share one connection and one commit. And the balance check is a read-modify-write, which is racy on its own: two concurrent transfers can both read the same balance and both pass. forUpdate() takes a row lock so the second waits, but it is dialect-dependent and SQLite does not support it. Where it is unavailable, push the check into the update:
const updated = await trx('accounts')
.where({ id: fromId })
.andWhere('balance_cents', '>=', amountCents)
.decrement('balance_cents', amountCents);
if (updated === 0) throw new Error('insufficient funds');On PostgreSQL and MySQL, Knex resolves update statements with the number of affected rows, so a zero result means the guard failed. This version evaluates the check atomically in the database and usually removes the need for the initial SELECT.
Savepoints, isolation levels, and dialect reality
knex.transaction({ isolationLevel: 'serializable' }, cb) sets the isolation level; PostgreSQL and MySQL honor it, while SQLite largely ignores it. Savepoints, via trx.savepoint() or by passing an existing trx into a nested knex.transaction(trx) call, let you roll back part of a multi-step write without discarding the whole transaction. Support and exact behavior vary by dialect and Knex version — check the TypeScript types or changelog for what you have installed rather than assuming.
The trade-off: a transaction holds a pooled connection
A transaction draws one connection from the pool for its entire lifetime, so a slow transaction is a connection other requests cannot use. Under concurrency, a few long transactions can exhaust the pool and stall unrelated queries. Keep them short, and never await an external HTTP call or queue publish inside one — that latency becomes pool pressure and lock contention. Sometimes the answer is not a transaction at all: if the operation is a single atomic UPDATE ... WHERE, you may not need one.
How to check your own code
- Search transaction callbacks for
knex(where you meanttrx(. A repository layer that takes the connection as its first argument makes this mechanical rather than a review habit. - Exercise the rollback path deliberately: force the insufficient-funds branch and confirm both balances are unchanged afterward.
- Enable driver logging or inspect
pg_stat_activityforidle in transactionsessions. More than one connection per request suggests a query escaped the transaction. - Check the installed version's types before using
isolationLevel,savepoint(), orforUpdate()in production code.
The wrapper is not the hard part. Passing trx to every statement, keeping the block short, and testing the failure branch are what make atomicity real rather than nominal.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.