> Every UQL docs page, as Markdown: https://uql-orm.dev/llms.txt
> The same docs over MCP: https://uql-orm.dev/mcp
> Before writing UQL code, read the skill: https://uql-orm.dev/.well-known/agent-skills/uql-orm/SKILL.md

# Transactions

> Run several writes all-or-nothing, nest them as savepoints, and pick an isolation level.

Source: https://uql-orm.dev/querying/transactions

A transaction runs a group of statements all-or-nothing. Pass a callback to `pool.transaction()`: UQL takes a connection, commits when the callback returns, rolls back when it throws, and releases the connection either way.

```ts
import { Profile, User } from './shared/models/index.js';
import { pool } from './uql.config.js';

const userId = await pool.transaction(async (querier) => {
  const id = await querier.insertOne(User, { name: 'Alice' });
  await querier.insertOne(Profile, { userId: id, bio: '...' });
  return id;
});
```

A pool call made inside the callback joins the transaction. `pool.insertOne(...)`, or a helper written against the pool or a [`UniversalQuerier`](https://uql-orm.dev/querying/querier.md#accept-a-universalquerier), runs on the callback’s querier, sees its uncommitted writes and rolls back with them. Helpers need no changes to take part: the caller decides what is atomic.

To undo writes, throw. To give up before writing anything, return early: nothing is committed and any row locks are released.

## On a querier you already hold

`querier.transaction()` makes part of a querier’s work atomic. It commits or rolls back, and releasing the connection stays with whoever acquired it:

```ts
const userId = await pool.withQuerier(async (querier) => {
  if (await querier.count(User, { $where: { email } })) {
    return undefined;
  }
  return querier.transaction(async () => {
    const id = await querier.insertOne(User, { name: 'Alice' });
    await querier.insertOne(Profile, { userId: id, bio: '...' });
    return id;
  });
});
```

If you acquire the querier yourself, `await using` releases it however the block exits, so an early return or a throw cannot leak the connection. Node 24+ and Bun run it natively, and TypeScript lowers it for an older target. A `try` / `finally` calling `querier.release()` does the same.

```ts
await using querier = await pool.getQuerier();
await querier.transaction(async () => {
  /* ... */
});
```

Releasing a querier with a transaction still open rolls it back and logs a warning. A connection whose rollback fails is discarded rather than reused. Releasing inside a `transaction()` callback makes that call reject, since nothing is left to commit. A released querier cannot be used again.

## Nesting

A `transaction()` inside another runs as a savepoint. If it throws, only its own writes roll back, and the outermost call still owns the commit. A helper that is atomic on its own is therefore safe to call from a larger transaction:

```ts
await pool.transaction(async (querier) => {
  await querier.insertOne(Order, order);
  await querier
    .transaction(() => querier.insertOne(Coupon, coupon))
    .catch(() => undefined); // the coupon rolled back; the order still commits
});
```

An inner failure you do not catch reaches the outer callback and rolls back everything.

> **MongoDB**
>
> MongoDB has no savepoints, so it refuses a `transaction()` inside another. Call the inner work directly as part of the outer transaction.

## After the commit: `onCommit`

Some side effects cannot be rolled back, such as sending an email or queueing a job. `querier.onCommit(fn)` runs `fn` once the outermost transaction commits:

```ts
await pool.transaction(async (querier) => {
  await querier.insertOne(User, user);
  await querier.onCommit(() => mailer.welcome(user.email));
});
```

If the transaction rolls back, `fn` never runs, and a savepoint that rolls back drops the callbacks registered inside it. Outside a transaction, `fn` runs at once. If `fn` throws, the `transaction` call rejects, but the writes are already committed. [Lifecycle hooks](https://uql-orm.dev/entities/lifecycle-hooks.md) get `onCommit` in their context.

## Manual begin, commit and rollback

When a transaction cannot be one callback, drive it yourself. A test that rolls back everything its body wrote is the usual case:

```ts
import type { Querier } from 'uql-orm';

let querier: Querier;

beforeEach(async () => {
  querier = await pool.getQuerier();
  await querier.beginTransaction();
});

afterEach(async () => {
  await querier.rollbackTransaction();
  await querier.release();
});
```

`beginTransaction()` throws `pending transaction` if one is already open on that querier. `rollbackTransaction()` does nothing when none is open, so it is safe in a `catch` or `finally`. `commitTransaction()` throws when none is open, so you never mistake uncommitted work for committed. Pool calls do not join a transaction opened this way, so run its statements on the querier.

## Concurrency

A transaction holds its connection until it ends. A statement from outside the callback waits for it instead of landing in it, whether it comes from another request sharing the querier or, on SQLite, PGlite, libSQL and embedded Turso, from another querier on the one shared connection. Transactions on one querier run one after another.

## Isolation levels

Every form takes an `isolationLevel`: `read uncommitted`, `read committed` (the default on most databases), `repeatable read` or `serializable`.

```ts
await pool.transaction(
  async (querier) => {
    /* ... */
  },
  { isolationLevel: 'serializable' },
);
```

PostgreSQL, MySQL, MariaDB and SQL Server take all four, and so does Bun SQL on PostgreSQL, MySQL and MariaDB. CockroachDB takes the option too, and runs a level it lacks at a stricter one. SQLite, libSQL, Turso and MongoDB ignore it; SQLite is already serializable. Cloudflare D1 has no transactions at all, so `transaction()` and `beginTransaction()` throw there: write the change as one statement, or make it idempotent.

At stricter levels, conflicting transactions fail with a serialization error or a deadlock instead of waiting. [`queryErrorKind`](https://uql-orm.dev/querying/errors.md#retrying-a-transaction) names both `retryable` on every engine, so you can run the whole transaction again.

Set the level on every transaction that needs it. On MySQL and MariaDB it is sent as its own statement before `START TRANSACTION`, so if `START TRANSACTION` fails, the level carries over to the next transaction on that pooled connection.

For a read-modify-write at the default level, lock the rows you read with `$lock`, or one update can be lost. See [Row Locking](https://uql-orm.dev/querying/locking.md).

## Read-only transactions

`{ readOnly: true }` opens a transaction that only reads, alone or beside an `isolationLevel`:

```ts
const users = await pool.transaction(
  (querier) => querier.findMany(User, { $select: { id: true } }),
  {
    readOnly: true,
  },
);
```

PostgreSQL, CockroachDB, MySQL, MariaDB and libSQL refuse a write inside it. libSQL and Turso open every other transaction with the write lock taken, so a read-only one is what lets a long read run beside the writers. SQLite, SQL Server and MongoDB have no such mode and run it as any other.

## Next steps

- [Pool vs. Querier](https://uql-orm.dev/querying/querier.md#choosing-poolx-vs-querierx): which entry point a unit of work needs.
- [Error Handling](https://uql-orm.dev/querying/errors.md): retrying a transaction that failed on a conflict.
- [Row Locking](https://uql-orm.dev/querying/locking.md): locking rows inside a transaction.
