Knex.js Transactions: Threading `trx` Through Every Query
Partial writes corrupt ledgers when a debit succeeds but a credit fails. Knex's transaction callback form makes the boundary explicit, but only if every query is bound to the `trx` object.
27 Aug 2026, 19:27 UTC

The half-finished transfer
You move money between two accounts: debit one row, credit another. If the debit commits and the credit fails, your ledger is now wrong. The classic fix is a database transaction, but in Knex.js the transaction only protects the queries that are actually bound to it. A single statement left on the global knex builder runs outside the boundary and stays committed even when the transaction rolls back.
Make the boundary explicit with the callback form
Knex offers two ways to open a transaction. The manual form gives you a transaction object and expects you to call commit() or rollback() yourself. The callback form wraps the whole unit of work:
await knex.transaction(async (trx) => {
// queries here
});
Knex commits when the callback resolves and rolls back when it throws or rejects. That removes the most common bug: forgetting to settle the transaction after an error. The trade-off is that the callback must contain every statement that belongs to the unit of work.
Worked example: an account transfer
Suppose you have an accounts table with a balance column. The transfer must decrement the source and increment the destination. Both statements use .transacting(trx):
await knex.transaction(async (trx) => {
await knex('accounts')
.where({ id: fromId })
.transacting(trx)
.decrement('balance', amount);
await knex('accounts')
.where({ id: toId })
.transacting(trx)
.increment('balance', amount);
});
If the second update fails, the first is rolled back because it shares the same trx. If you omit .transacting(trx) on either line, that query uses a different connection from the pool and is not part of the transaction. The same rule applies to reads: if a SELECT must observe uncommitted writes inside the transaction, it also needs .transacting(trx).
Manual transactions and the double-settle trap
Sometimes you cannot settle the transaction inside a single callback—for example, when the commit decision depends on an external event. The manual form still works:
const trx = await knex.transaction();
try {
await knex('accounts').where({ id: fromId }).transacting(trx).decrement('balance', amount);
await knex('accounts').where({ id: toId }).transacting(trx).increment('balance', amount);
await trx.commit();
} catch (err) {
await trx.rollback();
throw err;
}
The danger is double-settling. If commit() succeeds but the surrounding code throws before you return, a blind rollback() in the catch block will fail or behave unpredictably. Guard the rollback path or prefer the callback form whenever the unit of work fits inside one function.
Pool occupancy and dialect limits
A transaction holds one pooled connection from the first query until it settles. Long-running transactions reduce throughput and can surface as acquire timeouts under load. Pool settings such as pool.min, pool.max, and acquire timeouts are driver- and workload-sensitive; test under concurrency rather than copying values from a tutorial.
Nested transaction behavior is not universal. Savepoints and partial rollback semantics vary by database and driver. Treat an inner transaction block as dialect-specific until you have verified it against your stack. Knex also does not make a non-transactional storage engine transactional; confirm your database session and engine settings separately.
Check it with a failing integration test
The fastest way to prove the boundary works is to break it on purpose. Write an integration test that starts a transfer, forces the second update to throw, and asserts both balances are unchanged after the callback rejects. Then remove .transacting(trx) from the second query and confirm the test fails—this proves the binding is doing the work, not an accident of ordering.
Run the suite against every supported dialect in CI. Check your installed version with npm ls knex and compare transaction, savepoint, and pool behavior against your database driver documentation. Under load, enable pool logging to confirm transactions stay short and connections are not exhausted.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.