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.
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 },
});
});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,
},
},
);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).
Options
await client.transaction(
async (tx) => tx.users.findMany({ take: 10 }),
{
isolationLevel: 'serializable',
readOnly: true,
timeoutMs: 1000,
name: 'users-list',
comment: 'users.list.transaction',
context: { requestId: 'req_123' },
},
);| 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 |
context | custom context merged into the transaction scope |
name | name for the transaction / savepoint |
comment | comment attached to the transaction |
SQLite ignores some options
isolationLevel and readOnly are no-ops on SQLite. 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, {
schema,
locks: {
transactionsOnly: true,
},
});