Using Knex.js Transactions with async/await for Reliable Writes
Learn how to wrap multiple Knex queries in a transaction that automatically commits on success or rolls back on error, using async/await to keep your code clean and safe.
28 Jun 2026, 20:54 UTC

Problem: Keeping Related Writes Consistent
Imagine you need to create a user record and a related profile record. If the profile insert fails, you don’t want the user row left behind. Doing this with separate queries leaves a window where partial data can persist, and manual error handling quickly becomes noisy.
Takeaway
Use Knex’s transaction method with an async callback. All queries that receive the transaction object share the same DB transaction; Knex commits automatically when the callback resolves, and rolls back when it throws or returns a rejected promise. This gives you a concise, reliable way to group writes.
How Knex.transaction Works
Calling knex.transaction(callback) starts a transaction, passes a transaction object (trx) to the callback, and returns a promise that settles when the callback settles. If the callback resolves, Knex issues COMMIT; if it rejects, Knex issues ROLLBACK. The connection is returned to the pool automatically after settlement.
Async/Await Pattern
Because the callback can be async, you can await each query inside it and return a value that becomes the resolved value of the transaction promise:
const userId = await knex.transaction(async trx => {
const [{ id }] = await trx('users').insert({ name: 'Ada' }).returning('id');
await trx('profiles').insert({ user_id: id, bio: 'Engineer' });
return id; // becomes the transaction’s resolved value
});
console.log('Created user', userId);
If either insert throws, the transaction is rolled back and the await line throws, letting you handle the error with a usual try/catch block.
Pitfalls to Avoid
- Long‑running work inside the transaction: Expensive computations or external API calls keep locks held longer, increasing the chance of timeouts or deadlocks. Keep the callback focused on DB operations only.
- Manual commit/rollback: If you call
trx.commit()ortrx.rollback()yourself, you must not also resolve or reject the callback; otherwise Knex sees a double resolution and may hang or throw. - Nested transactions: Knex implements them as savepoints. Not all databases handle savepoints identically, and errors can propagate differently. Test your target DB if you need nesting.
Worked Example: User + Profile Insert
The following script shows a complete flow, including error handling and a simple verification step.
const knex = require('knex')({
client: 'pg',
connection: process.env.PG_CONNECTION_STRING,
pool: { min: 2, max: 10 }
});
async function createUserWithProfile(name, bio) {
return await knex.transaction(async trx => {
const [{ id }] = await trx('users')
.insert({ name })
.returning('id');
await trx('profiles').insert({ user_id: id, bio });
return id;
});
}
// Usage
createUserWithProfile('Ada', 'Engineer')
.then(id => console.log(`Success: user ${id}`))
.catch(err => {
console.error('Transaction failed, changes rolled back:', err.message);
})
.finally(() => knex.destroy());
To verify that the rollback works, you can temporarily throw after the first insert:
await knex.transaction(async trx => {
await trx('users').insert({ name: 'Test' });
throw new Error('forced rollback');
// next line never runs
await trx('profiles').insert({});
});
// Afterwards, SELECT * FROM users WHERE name='Test'; returns no rows.
Checking the Transaction Boundaries
You can observe the SQL that Knex sends by enabling debug logging:
const knex = require('knex')({ client: 'pg', connection: ..., debug: true });
The console will show lines like:
BEGINinsert into "users" ...insert into "profiles" ...COMMIT(orROLLBACKon error)
Additionally, if you use the built‑in pool, you can confirm the connection is returned:
console.log('free before:', knex.pool.availableResources.length);
await knex.transaction(async trx => { /* work */ });
console.log('free after:', knex.pool.availableResources.length);
// Should be the same number.
Trade‑off: Transaction Size vs. Simplicity
Wrapping many statements in a single transaction gives you atomicity but holds locks longer. For bulk operations, consider batching (e.g., inserting 100 rows per transaction) or using optimistic locking with a version column instead of a giant transaction. The key is to keep the transaction scope as short as your consistency requirement allows.
Actionable Closing
When you need multiple writes to succeed or fail together:
- Wrap them in
knex.transaction(async trx => { ... }). - Use
await for each query and return any value you need from the callback. - Keep the callback focused on DB work; move heavy logic outside.
- Handle errors with a surrounding
try/catchor.catch. - Verify with debug logs or pool checks that connections are returned and only the intended rows persist.
Following this pattern gives you reliable, readable data operations without manual commit/rollback boilerplate.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.