Transactions & Batching
Execute multiple operations with callback transactions or atomic batch mode
VibORM gives you two supported ways to run several operations together, and both are atomic — if any operation fails, none are applied:
- Interactive transactions — a callback where later operations can use results from earlier ones.
- Batch mode — an array of independent operations that share one atomic boundary.
Dynamic Transactions
Use the callback API when operations need to depend on each other:
await client.$transaction(async (tx) => {
// Create a user
const user = await tx.user.create({
data: { name: "Alice", email: "alice@example.com" },
});
// Use the created user's ID in the next operation
await tx.post.create({
data: {
title: "Hello World",
authorId: user.id, // Depends on previous operation
},
});
});
Transaction options
$transaction accepts a second argument with the same shape Prisma uses:
await client.$transaction(
async (tx) => {
/* ... */
},
{
isolationLevel: "Serializable", // "ReadUncommitted" | "ReadCommitted" | "RepeatableRead" | "Serializable"
timeout: 10_000, // ms the callback may run before rollback
maxWait: 2000, // ms to wait for a transaction slot
}
);
The array form takes isolationLevel only — a preplanned array has no
interactive window for timeout or maxWait to bound:
await client.$transaction([client.user.findMany(), client.user.count()], {
isolationLevel: "Serializable",
});
Honored or refused, never ignored
Each option is honored where the driver can honor it, and rejected with a typed
UnsupportedOperationError (V8003) where it cannot. An option you pass never
silently does nothing. A malformed options object — an unknown key, a
misspelled level, a non-positive duration — is a TransactionError (V5005),
raised before any provider work begins.
| Driver | isolationLevel |
timeout |
maxWait |
|---|---|---|---|
| pg | all four levels, SET TRANSACTION ISOLATION LEVEL after BEGIN |
honored | honored — bounds the pool acquisition |
| postgres.js | all four levels, first statement in the transaction | honored | refused — sql.begin() owns acquisition; the wait cannot be bounded |
| PGlite | all four levels, first statement in the transaction | honored | honored — bounds the connection queue |
| Bun SQL | all four levels, first statement in the transaction | honored | refused — sql.begin() owns acquisition |
| mysql2 | all four levels, SET TRANSACTION ISOLATION LEVEL before BEGIN |
honored | honored — bounds the pool acquisition |
| PlanetScale | all four levels, before BEGIN on the transaction’s connection |
honored | refused — each transaction opens its own HTTP connection with no wait |
| SQLite3 | Serializable only |
honored | honored — bounds the connection queue |
| Bun SQLite | Serializable only |
honored | honored — bounds the connection queue |
| libSQL | Serializable only |
honored | honored — queue (in-memory) or transaction() acquisition |
| D1 binding | refused — batch-only, no transaction to configure | refused | refused |
| Neon HTTP | refused — batch-only, no transaction to configure | refused | refused |
Isolation levels
PostgreSQL-family drivers accept all four levels and apply them with
SET TRANSACTION ISOLATION LEVEL as the transaction’s first statement.
PostgreSQL itself treats ReadUncommitted as ReadCommitted — that is the
server’s own documented behavior, and VibORM does not paper over it.
MySQL-family drivers issue the same statement before BEGIN, where it binds
to exactly the next transaction on that connection. It deliberately does not
change the session default, so a pooled connection never carries one caller’s
isolation level over to the next.
SQLite-family drivers (SQLite3, Bun SQLite, libSQL) have no isolation-level
statement: a transaction on their single connection is already serializable.
Serializable is therefore honored by construction — nothing is emitted,
because nothing needs to be. The three weaker levels are refused rather than
silently upgraded, since accepting them would misreport what the transaction
actually guarantees.
timeout
timeout bounds how long the callback body may run. On expiry the transaction
is rolled back and the call rejects with a TransactionError (V5002). In-flight
statements are drained before the rollback, so the connection is returned in a
clean, reusable state and no partial write survives.
maxWait
maxWait bounds the wait for a transaction slot — before the body starts. A
transaction that exceeds it never reaches BEGIN, so there is nothing to roll
back; the call rejects with a TransactionError (V5002). Drivers that cannot
observe or safely abandon their acquisition refuse the option instead of
accepting a bound they could not enforce.
Nested transactions
A nested $transaction runs as a SAVEPOINT inside the outer transaction, so
its option contract differs: timeout is honored (expiry rolls back to the
savepoint), while isolationLevel and maxWait are refused. The outer
transaction’s isolation level is already fixed and cannot be changed
mid-transaction, and a savepoint reuses the connection the outer transaction
already holds. Set isolationLevel on the outermost $transaction.
Raw SQL inside a transaction
Use the tx client’s own raw methods — tx.$queryRaw, tx.$executeRaw and
their Unsafe variants run on the transaction’s connection and roll back with
it. The originating client.$queryRaw does not guarantee a pinned session on
pooled or HTTP drivers. See Raw SQL.
Use only the supplied transaction client
Portable callback-transaction code performs database work through the tx
client supplied to the callback. Starting work through the originating client
while that callback is active is outside the portable contract because drivers
have different connection topologies: single-connection drivers reject that
work fail-closed, and pooled drivers are not globally serialized. Do not depend
on originating-client concurrency inside a callback transaction.
At every nesting level, use the client supplied to that callback. Parent-scope work admitted before nested activation is serialized after it; new parent-scope calls during the nested callback reject fail-closed.
Batch Mode (Prisma-style)
Use the array API for independent operations that should run together atomically. Sibling operations can’t read each other’s results, but a nested write inside a single operation works normally.
The array takes model operations only. A raw query runs the moment you call
it and returns a plain Promise, so there is nothing left to batch — passing one
is refused with a typed UnsupportedOperationError. Run raw SQL inside the
interactive form instead.
const [users, posts, count] = await client.$transaction([
client.user.findMany({ where: { active: true } }),
client.post.findMany({ where: { published: true } }),
client.user.count(),
]);
Batch Writes
const [user1, user2] = await client.$transaction([
client.user.create({ data: { name: "Alice", email: "alice@example.com" } }),
client.user.create({ data: { name: "Bob", email: "bob@example.com" } }),
]);
console.log(user1.name); // "Alice"
console.log(user2.name); // "Bob"
Mixed Operations
const [newUser, allPosts, userCount] = await client.$transaction([
client.user.create({ data: { name: "Charlie", email: "charlie@example.com" } }),
client.post.findMany(),
client.user.count(),
]);
Operations are lazy
Operations don’t run until you await them (or pass them to $transaction([...])). This lets you build an operation and run it later:
// Build an operation without running it
const findUsersOp = client.user.findMany();
// Run it later
const users = await findUsersOp;
// Or run it as part of a batch
const [users, posts] = await client.$transaction([
findUsersOp,
client.post.findMany(),
]);
Driver support
Every connection-oriented driver supports both forms. D1 Workers bindings and
Neon HTTP are batch-only, so their callback form throws. Cloudflare D1 support
uses the Workers binding: its batch() primitive is the documented atomic path
required by VibORM. An empty array always returns [] without contacting a
provider.
The difference
Interactive transactions give you read-your-writes: later operations can use the results of earlier ones.
Batch mode runs independent operations under one atomic boundary, but siblings can’t read each other’s results. Drivers with native batch APIs may submit one provider call; connection-oriented drivers may execute sequential statements inside a transaction.
Both are all-or-nothing.
Choosing the Right Approach
Use Dynamic Transactions When:
- Operations depend on each other’s results
- You need isolation from other connections
- You’re using a driver that supports transactions
// Good: user.id is needed for posts
await client.$transaction(async (tx) => {
const user = await tx.user.create({ data: { name: "Alice" } });
await tx.post.createMany({
data: [
{ title: "Post 1", authorId: user.id },
{ title: "Post 2", authorId: user.id },
],
});
});
Use Batch Mode When:
- Operations are independent (any dependency inside a single nested write is fine)
- You want independent operations to share one atomic boundary
- You’re on a serverless driver that only supports batch mode (see Driver support)
// Good: sibling operations can be planned up front
const [users, posts, stats] = await client.$transaction([
client.user.findMany(),
client.post.findMany({ where: { published: true } }),
client.user.aggregate({ _count: true, _avg: { age: true } }),
]);
Error handling
If any operation in a supported transaction or atomic batch fails, the whole thing is rolled back — you never end up with a partial write.
If rollback or savepoint cleanup also fails, the original operation failure
remains the primary error and cleanup failures are attached in an
AggregateError. A driver whose transaction state can no longer be proven safe
is closed or poisoned instead of being reused.