# Transactions

> Client-level transactions with nested savepoints, explicit rollback, lifecycle callbacks, retries, and options.

URL: https://better-drizzle.com/docs/advanced/transactions

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.



**better-drizzle**

```ts  
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;
});
```

**Drizzle**

```ts  
const user = await db.transaction(async (tx) => {
	const [created] = await tx
		.insert(users)
		.values({ email: 'new@example.com', name: 'New User', active: true })
		.returning();

	await tx
		.insert(posts)
		.values({ 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](/docs/guides/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:



**better-drizzle**

```ts  
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 },
	});
});
```

**Drizzle**

```ts  
await db.transaction(async (tx) => {
	await tx
		.insert(users)
		.values({ id: 1, email: 'a@test.com', name: 'Alice', active: true });

	try {
		await tx.transaction(async (nested) => {
			await nested
				.insert(users)
				.values({ id: 2, email: 'b@test.com', name: 'Bob', active: true });
			nested.rollback();
		});
	} catch (error) {
		// only the nested savepoint was rolled back
	}

	await tx
		.insert(users)
		.values({ 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:



**better-drizzle**

```ts  
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 },
	});
});
```

**Drizzle**

```ts  
import { eq } from 'drizzle-orm';

await db.transaction(async (tx) => {
	const [existing] = await tx
		.select({ id: users.id })
		.from(users)
		.where(eq(users.email, 'alice@example.com'))
		.limit(1);

	if (existing) tx.rollback();

	await tx
		.insert(users)
		.values({ 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":



**better-drizzle**

```ts  
await client.transaction(async (tx) => {
	tx.afterCommit(() => sendWelcomeEmail());
	tx.afterRollback(() => cleanupTempFiles());

	await tx.users.create({
		data: { email: 'x@example.com', name: 'X', active: true },
	});
});
```

**Drizzle**

```ts  
// no lifecycle callbacks: run side effects after the transaction settles
try {
	await db.transaction(async (tx) => {
		await tx
			.insert(users)
			.values({ email: 'x@example.com', name: 'X', active: true });
	});
	sendWelcomeEmail();
} catch (error) {
	cleanupTempFiles();
	throw error;
}
```

## Retries

Automatic retries are opt-in. Configure how many attempts, which failures qualify, and the backoff:



**better-drizzle**

```ts  
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,
		},
	},
);
```

**Drizzle**

```ts  
// no built-in retries: loop by hand (PostgreSQL deadlock / serialization codes)
for (let attempt = 1; ; attempt += 1) {
	try {
		await db.transaction(async (tx) => {
			await tx
				.insert(users)
				.values({ email: 'retry@example.com', name: 'Retry', active: true });
		});
		break;
	} catch (error) {
		// Drizzle 1.x wraps driver errors; the driver code is on `cause`
		const code = (error as { cause?: { code?: string } }).cause?.code;
		if (attempt >= 3 || (code !== '40P01' && code !== '40001')) throw error;
		await new Promise((resolve) => setTimeout(resolve, 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



**better-drizzle**

```ts  
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' },
	},
);
```

**Drizzle**

```ts  
// PostgreSQL; no timeout, name, meta, or context equivalents
await db.transaction(
	async (tx) => tx.select().from(users).limit(10),
	{
		isolationLevel: 'serializable',
		accessMode: 'read only',
	},
);
```

| 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 only:**

> **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](/docs/reference/client).

## Raw SQL inside a transaction

Raw methods bind to the transaction-scoped client, so they run inside the same transaction:



**better-drizzle**

```ts  
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
	`;
});
```

**Drizzle**

```ts  
import { sql } from 'drizzle-orm';

// node-postgres driver
await db.transaction(async (tx) => {
	await tx.execute(sql`update users set active = ${true} where id = ${2}`);

	const { rows } = await tx.execute<{ id: number }>(sql`
		select id from users where active = ${true} order by id asc
	`);
});
```

Plugins and hooks can read `transactionContext` and `isInTransaction` throughout.

**PostgreSQL, MySQL only:**

## Locked reads

Row locks (`FOR UPDATE`, `FOR SHARE`, `skipLocked`, `noWait`, table targeting) are covered in detail on the [row locks page](/docs/advanced/locks).

The most common pattern is combining locks with transactions:



**better-drizzle**

```ts  
await client.transaction(async (tx) => {
	return tx.posts.findMany({
		where: { id: { gt: 0 } },
		lock: {
			mode: 'update',
			skipLocked: true,
		},
	});
});
```

**Drizzle**

```ts  
import { gt } from 'drizzle-orm';

await db.transaction(async (tx) => {
	return tx
		.select()
		.from(posts)
		.where(gt(posts.id, 0))
		.for('update', { skipLocked: true });
});
```

You can enforce that all locked reads happen inside transactions at the client level:

```ts
const client = better(db, {
	locks: {
		transactionsOnly: true,
	},
});
```
