Choosing Transaction Strategies in Knex.js: Auto‑Commit, Savepoints, and Isolation Levels
A decision guide for Knex.js transaction strategies: when to rely on auto‑commit, how to use explicit transactions with savepoints, and how to pick isolation levels for PostgreSQL and MySQL without starving the connection pool.
16 Oct 2025, 14:23 UTC

Decision: When to Use Explicit Transactions
Knex.js does not wrap every query in a transaction by default. Each statement runs in auto‑commit mode unless you call knex.transaction(). This behavior differs from ORMs that open a transactional session automatically. The choice between implicit auto‑commit, explicit transactions, savepoints, and isolation levels determines data consistency, lock duration, and connection‑pool pressure.
Options Comparison
| Strategy | How to Invoke | Typical Use Case | Pool Impact | Rollback Granularity |
|---|---|---|---|---|
| Implicit auto‑commit | Plain query builder calls (e.g., knex('users').insert(...)) |
Read‑only or idempotent writes where a single statement is atomic enough | Connection returned immediately after each statement | Statement‑level only |
| Explicit transaction | knex.transaction(trx => { … }) |
Multi‑step writes that must succeed or fail together | One connection held for the whole callback | All‑or‑nothing (full rollback on error) |
| Savepoints (nested transactions) | trx.savepoint() / trx.rollbackTo(sp) / trx.release(sp) (Knex 2.5+ auto‑creates savepoints for nested transaction() calls) |
Partial rollback inside a larger unit of work (e.g., retry a sub‑operation) | Same connection as outer transaction | Rollback to a named savepoint only |
| Isolation level per transaction | knex.transaction({ isolationLevel: 'serializable' }, trx => { … }) (PostgreSQL) or 'repeatable-read' (MySQL default) |
Prevent phantom reads, enforce strict serializability | Same connection; higher levels may increase lock contention | Full transaction rollback on serialization failure |
Trade‑offs
- Consistency vs. throughput: Higher isolation levels (serializable) reduce anomalies but increase row‑level locks and deadlock probability.
- Pool starvation: A long‑running transaction holds a connection from the pool. Keep callbacks short; avoid async I/O (HTTP calls, file reads) inside the transaction.
- Savepoint overhead: Each savepoint adds a
SAVEPOINT/RELEASE SAVEPOINTround‑trip. Use only when partial rollback is genuinely needed. - MySQL validation: Knex passes the isolation string unchanged. An invalid level silently falls back to the server default, so verify with
SHOW VARIABLES LIKE 'transaction_isolation'after setting. - SQLite limitation: In‑memory SQLite uses a single connection;
savepoint()throws. Avoid savepoints when targeting SQLite.
Concrete Implementation
The following TypeScript snippet demonstrates an explicit transaction with a savepoint and a serializable isolation level on PostgreSQL. It inserts an order, creates a savepoint, attempts a risky inventory update, rolls back to the savepoint on failure, then commits the order.
import Knex from 'knex';
const knex = Knex({ client: 'pg', connection: process.env.PG_URL, pool: { min: 2, max: 10 } });
interface OrderRow { id: number; customer_id: number; total: number; }
interface InventoryRow { product_id: number; qty: number; }
async function placeOrder(customerId: number, items: { productId: number; qty: number }[]) {
return knex.transaction({ isolationLevel: 'serializable' }, async (trx) => {
// 1️⃣ Insert order
const [order] = await trx('orders').insert({ customer_id: customerId, total: 0 }).returning('*');
let total = 0;
// 2️⃣ Create a savepoint for inventory work
const sp = await trx.savepoint('inventory_sp');
try {
for (const { productId, qty } of items) {
const [inv] = await trx('inventory')
.where('product_id', productId)
.decrement('qty', qty)
.returning('*');
if (inv.qty < 0) throw new Error(`Insufficient stock for ${productId}`);
total += inv.qty * 0; // placeholder for price calc
}
await trx.release(sp); // commit savepoint
} catch (err) {
await trx.rollbackTo(sp); // undo only inventory changes
throw err; // will trigger outer rollback
}
// 3️⃣ Update order total and commit
await trx('orders').where('id', order.id).update({ total });
return order;
});
}
Key points:
- The transaction options object sets
isolationLevel: 'serializable'; Knex emitsSET TRANSACTION ISOLATION LEVEL SERIALIZABLEbeforeBEGIN. trx.savepoint()returns a name you pass torollbackToandrelease.- Nested
knex.transaction()calls inside this callback would automatically create savepoints (Knex 2.5+), but the explicit API gives you control. - The generic
Transaction<OrderRow>provides typed query builders fortrx<OrderRow>('orders').
Verification Steps
- Log transaction SQL: Run the process with
DEBUG=knex:tx node your-script.js. You should seeBEGIN,SAVEPOINT inventory_sp,ROLLBACK TO SAVEPOINT inventory_sp,RELEASE SAVEPOINT inventory_sp, andCOMMITin the console. - Unit test the savepoint behavior: Write a test that starts a transaction, inserts a row, creates a savepoint, inserts a second row, rolls back to the savepoint, commits, then asserts only the first row exists.
- Observe lock differences: On PostgreSQL, run
EXPLAIN ANALYZE SELECT * FROM orders WHERE id = 1inside a serializable transaction and compare with the default read‑committed run to see additional predicate locks. - Simulate pool exhaustion: Configure
pool: { min: 1, max: 1 }, start a long transaction (e.g.,await new Promise(r => setTimeout(r, 5000))), then fire concurrent queries. The second query should reject with aTimeoutErrorbeforeBEGIN.
Limitations & Gotchas
- Knex 3.x removed
transacting(trx)from the schema builder. Useknex.schema.withSchema('public').createTable('t', ...).transacting(trx)instead. - Using
knex.raw('SET TRANSACTION ...')inside a transaction callback breaks Knex’s internal state; always rely on the options object. - MySQL 8.0 supports
READ COMMITTED,REPEATABLE READ,SERIALIZABLE.READ UNCOMMITTEDis allowed but not recommended; Knex does not validate the string. - Distributed transactions (two‑phase commit) are not built‑in. Implement saga compensating actions in application code and call
trx.commit()/trx.rollback()explicitly.
Practical Checklist
- Default to auto‑commit for single‑statement writes.
- Wrap multi‑statement business logic in
knex.transaction(). - Add savepoints only when a sub‑operation may fail independently.
- Choose the weakest isolation level that satisfies your consistency requirement.
- Keep transaction callbacks synchronous‑ish; offload I/O to after commit.
- Monitor pool usage (e.g.,
knex.client.pool.numUsed()) and adjustacquireTimeoutMillis.