Transactions
Client-level transactions with nested savepoints, explicit rollback, lifecycle callbacks, retries, and options.
Transactions live on the client, not on individual delegates. The callback receives a full Better client bound to the transaction, so delegates, plugins, hooks, and raw SQL all keep working inside it.
const user = await client.transaction(async (tx) => {
const created = await tx.users.create({
data: { email: 'new@example.com', name: 'New User', active: true },
});
await tx.posts.create({
data: { authorId: created.id, title: 'Hello', published: true },
});
return created;
});Whatever the callback returns becomes the result of transaction(...). Throwing (or an explicit rollback) aborts and rolls back.
When rollback succeeds, an error thrown by the callback is rethrown as is, so your own error classes (for example a NotFoundException) reach the caller unchanged. The onTransactionError hooks still receive it normalized as a BetterDrizzleError.
SQLite connections
Root transactions sharing the same SQLite connection queue while their database
transaction is open, including transactions started through separate Drizzle
clients for that connection. Commit and rollback notifications run after
releasing the connection.
Use the callback's tx client for every operation in the transaction. Ordinary
root-client queries and direct Drizzle calls are not queued: running them while
the callback awaits can execute them inside the open transaction.
The SQLite driver must execute BEGIN, COMMIT, ROLLBACK, and savepoints on
the same connection through run(). These statements are awaited, including
when the driver returns promises. The integration tests use Bun SQLite.
Cloudflare D1 has no transactions
D1 rejects BEGIN and SAVEPOINT, so on D1 the callback runs as separate
statements with no rollback. See Cloudflare D1.
If rollback itself fails, the transaction rejects with a BetterDrizzleError.
The original callback error is retained as its cause, and the driver rollback
error is available in details.rollbackError. Rollback notifications still run,
abort listeners are removed, and the failed attempt is not retried.
Nested transactions (savepoints)
A tx.transaction(...) inside another opens a savepoint that can be rolled back independently of the outer transaction:
await client.transaction(async (tx) => {
await tx.users.create({
data: { id: 1, email: 'a@test.com', name: 'Alice', active: true },
});
try {
await tx.transaction(async (nested) => {
await nested.users.create({
data: { id: 2, email: 'b@test.com', name: 'Bob', active: true },
});
nested.rollback('nested rollback');
});
} catch (error) {
// only the nested savepoint was rolled back
}
await tx.users.create({
data: { id: 3, email: 'c@test.com', name: 'Charlie', active: true },
});
});On SQLite, await each nested transaction before starting another on the same
parent. Overlapping sibling savepoints reject with INVALID_ARGS, because
releasing an earlier SQLite savepoint also removes later savepoints. Deeper
nesting remains supported. Await every nested transaction before returning
from the parent callback.
Explicit rollback
Call tx.rollback(reason) to abort. It throws, so code after it does not run:
await client.transaction(async (tx) => {
const exists = await tx.users.exists({
where: { email: 'alice@example.com' },
});
if (exists) tx.rollback('email already exists');
await tx.users.create({
data: { email: 'alice@example.com', name: 'Alice', active: true },
});
});Lifecycle callbacks
Register side effects that fire after the transaction settles - the right place for "send the email only if the write actually committed":
await client.transaction(async (tx) => {
tx.afterCommit(() => sendWelcomeEmail());
tx.afterRollback(() => cleanupTempFiles());
await tx.users.create({
data: { email: 'x@example.com', name: 'X', active: true },
});
});Retries
Automatic retries are opt-in. Configure how many attempts, which failures qualify, and the backoff:
await client.transaction(
async (tx) => {
await tx.users.create({
data: { email: 'retry@example.com', name: 'Retry', active: true },
});
},
{
retries: {
attempts: 3,
on: ['deadlock', 'serializationFailure'],
delayMs: (attempt) => attempt * 25,
},
},
);attempts is the total number of tries, so it must be at least 2 for any retry to happen (1 or less disables retries). on accepts 'connectionError', 'deadlock', and 'serializationFailure'; it defaults to all three. delayMs can be a fixed number or a function of the attempt (1-indexed) and defaults to 0 (retry immediately). An explicit tx.rollback() is never retried.
Options
await client.transaction(
async (tx) => tx.users.findMany({ take: 10 }),
{
isolationLevel: 'serializable',
readOnly: true,
timeoutMs: 1000,
name: 'users-list',
meta: { requestId: 'req_123' },
context: { source: 'admin' },
},
);| Option | Purpose |
|---|---|
isolationLevel | readUncommitted · readCommitted · repeatableRead · serializable |
readOnly | open the transaction read-only |
retries | automatic retry configuration |
timeoutMs | abort after a duration |
signal | an AbortSignal that rolls back on abort |
meta | metadata merged over the client's $withContext() defaults for every operation, raw call, and lifecycle hook inside the transaction |
context | custom context merged into the transaction scope |
name | label passed to transaction hooks; savepoints keep generated names |
comment | metadata passed to transaction hooks; not sent to the database, so it is reported as an unsupported option on every dialect |
SQLite ignores some options
isolationLevel and readOnly are no-ops on SQLite, and comment is
reported as unsupported on every dialect. You can configure how
unsupported options behave ('warn' by default, or 'throw' / 'ignore')
via the client's transaction config.
Raw SQL inside a transaction
Raw methods bind to the transaction-scoped client, so they run inside the same transaction:
await client.transaction(async (tx) => {
await tx.$executeRaw`update users set active = ${true} where id = ${2}`;
const rows = await tx.$raw<{ id: number }>`
select id from users where active = ${true} order by id asc
`;
});Plugins and hooks can read transactionContext and isInTransaction throughout.
Locked reads
Row locks (FOR UPDATE, FOR SHARE, skipLocked, noWait, table targeting) are covered in detail on the row locks page.
The most common pattern is combining locks with transactions:
await client.transaction(async (tx) => {
return tx.posts.findMany({
where: { id: { gt: 0 } },
lock: {
mode: 'update',
skipLocked: true,
},
});
});You can enforce that all locked reads happen inside transactions at the client level:
const client = better(db, {
locks: {
transactionsOnly: true,
},
});