Core concepts

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' },
	},
);
OptionPurpose
isolationLevelreadUncommitted · readCommitted · repeatableRead · serializable
readOnlyopen the transaction read-only
retriesautomatic retry configuration
timeoutMsabort after a duration
signalan AbortSignal that rolls back on abort
contextcustom context merged into the transaction scope
namename for the transaction / savepoint
commentcomment 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,
	},
});

On this page