# Hooks

> Client lifecycle hooks for auditing, tracing, metrics, and request-scoped logging.

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

Client hooks are the side-effect layer around Better operations. They are optional - if you do not need them, do not pass them. They are ideal for cross-cutting concerns you do not want duplicated in every call:

* audit trails
* tracing and metrics
* authorization checks
* request-scoped logging

You register hooks when creating the client:

```ts
const client = better(db, {
	hooks: {
		beforeCreate(ctx) {
			console.log('beforeCreate', ctx.action, ctx.table);
		},
		afterCreate(ctx) {
			console.log('afterCreate', ctx.row);
		},
		beforeQuery(ctx) {
			console.log('beforeQuery', ctx.action, ctx.args.where);
		},
		afterQuery(ctx) {
			console.log('afterQuery', ctx.action, ctx.result);
		},
	},
});
```

## Available hooks

**CRUD & query**

| Hook                           | `action` values                                                                           |
| ------------------------------ | ----------------------------------------------------------------------------------------- |
| `beforeCreate` / `afterCreate` | `create`, `createMany`, `upsert`, `upsertMany`                                            |
| `beforeUpdate` / `afterUpdate` | `update`, `updateMany`, `updateEach`                                                      |
| `beforeDelete` / `afterDelete` | `delete`, `deleteMany`                                                                    |
| `beforeQuery` / `afterQuery`   | `findMany`, `findFirst`, `findOne`, `findUnique`, `count`, `exists`, `paginate`, `cursor` |

`upsert` fires the create hooks only, with `action: 'upsert'`.

**Transaction**

* `beforeTransaction`
* `afterTransactionCommit`
* `afterTransactionRollback`
* `onTransactionError`

**Raw SQL**

* `beforeRaw`
* `afterRaw`
* `onRawError`

**Errors**

* `onError` - fires when an operation or a CRUD/query hook throws.

Hook return values are ignored. A hook that throws fails the call (a throwing before hook also prevents the database call). `onError` receives the error with code `HOOK_ERROR`; the caller gets a `BetterDrizzleError` with code `OPERATION_ERROR` that keeps the original message, `hookName`, and `stage`. `$withoutPlugins()` skips plugins, not client hooks.

## Hook context

### CRUD and query hooks

```js
{
  	action: { type: 'string', description: 'The operation, e.g. `create` or `findMany`.' },
  	table: { type: 'string', description: 'Schema key of the table.' },
  	args: { type: 'object', description: 'The call args, after plugin transforms.' },
  	meta: { type: 'Meta | undefined', description: 'Merged `$withContext(...)` and per-call `meta`.' },
  	comment: { type: 'string | undefined', description: 'The call\'s query comment. Before hooks change it by setting `args.comment`; see [query comments](/docs/querying/query-comments).' },
  	repository: { type: 'Delegate', description: 'The delegate for this table.' },
  	tableInstance: { type: 'Table', description: 'The Drizzle table object.' },
  	tableConfig: { type: 'object', description: 'Relational config for the table.' },
  	schema: { type: 'Schema', description: 'The relations config from defineRelations(...) that the Drizzle instance was created with.' },
  	db: { type: 'unknown', description: 'The bound Drizzle instance (the transaction inside `transaction(...)`).' },
  	options: { type: 'BetterClientOptions', description: 'Options passed to `better(...)`.' },
  	isInTransaction: { type: 'boolean', description: 'Whether the call runs inside a transaction.' },
  	transaction: { type: 'TransactionClient | null', description: 'The transaction client, or `null`.' },
  	transactionContext: { type: 'Record<string, unknown> | undefined', description: 'The `context` passed to `transaction(...)`.' },
  	afterCommit: { type: '(callback) => void', description: 'Queue a callback for after commit. Throws outside a transaction.' },
  	afterRollback: { type: '(callback) => void', description: 'Queue a callback for after rollback. Throws outside a transaction.' },
  	result: { type: 'unknown', description: 'After hooks only. The operation result.' },
  	row: { type: 'object | null', description: 'After hooks only, for `create`, `upsert`, `update`, `delete`, `findFirst`, `findOne`, and `findUnique`. Same value as `result`.' },
  	compiled: { type: 'Readonly<Record<string, unknown>> | undefined', description: 'After create/update hooks only. The final atomic `SET` expressions when the write used atomic mutations.' },
  	rows: { type: 'object[]', description: '`afterQuery` only, for `findMany`. Same value as `result`.' },
  }
```

Batch writes (`createMany`, `updateMany`, `updateEach`, `deleteMany`, `upsertMany`) have `result` only, a `{ count, data? }` summary.

### `onError`

`onError` receives the same fields as the CRUD context (without `result`, `row`, and `rows`), plus:

```js
{
  	error: { type: 'BetterDrizzleError', description: 'The normalized error.' },
  	stage: { type: "'beforeHook' | 'operation' | 'afterHook'", description: 'Where the error happened.' },
  	hookName: { type: 'string | undefined', description: 'The hook that threw, e.g. `beforeCreate`. Unset when the operation itself failed.' },
  }
```

It also fires when `.throw()` finds no row (`RESULT_NOT_FOUND`, stage `operation`). Errors thrown inside `onError` are swallowed.

### Transaction hooks

All four receive `client` (the transaction client), `name`, `comment`, `depth` (`1` for the outer transaction, higher for savepoints), `attempt` (starts at `1`), `meta`, `transactionOptions`, `transactionContext`, `isInTransaction: true`, `db`, `schema`, `options`, `afterCommit`, and `afterRollback`.

* `afterTransactionRollback` adds `reason`: the `rollback(...)` reason, or the error.
* `onTransactionError` adds `error`. It fires before `afterTransactionRollback`, and not for an explicit `rollback()`.

### Raw hooks

`beforeRaw`, `afterRaw`, and `onRawError` receive `action` (`'raw' | 'executeRaw' | 'rawUnsafe'`), `query` (rendered SQL text), `sql`, `rawOptions`, `name`, `comment`, `timeoutMs`, `signal`, `map`, `meta`, `isInTransaction`, `transaction`, `transactionContext`, `db`, `schema`, `options`, `afterCommit`, and `afterRollback`. `afterRaw` adds `result`, and `onRawError` adds `error`.

## Request metadata with `meta`

Every operation accepts a `meta` object. Use it to thread request-scoped context into hooks:

```ts
await client.users.findMany({
	where: { active: true },
	meta: { requestId: 'req_123', userId: 'admin_7' },
});
```

```ts
const client = better(db, {
	hooks: {
		beforeQuery(ctx) {
			console.log(ctx.meta?.requestId);
		},
	},
});
```

## Transaction lifecycle

```ts
const client = better(db, {
	hooks: {
		beforeTransaction(ctx) {
			console.log('tx start', ctx.name, ctx.depth);
		},
		afterTransactionCommit(ctx) {
			console.log('tx committed', ctx.attempt);
		},
		afterTransactionRollback(ctx) {
			console.log('tx rolled back', ctx.reason);
		},
		onTransactionError(ctx) {
			console.error('tx error', ctx.error);
		},
	},
});
```

## Raw SQL lifecycle

```ts
const client = better(db, {
	hooks: {
		beforeRaw(ctx) {
			console.log(ctx.action, ctx.comment, ctx.name);
		},
		afterRaw(ctx) {
			console.log(ctx.result);
		},
		onRawError(ctx) {
			console.error(ctx.query, ctx.error);
		},
	},
});
```

## Hooks vs plugins

> **Observe with hooks, change behavior with plugins:** Client hooks should **observe and coordinate** - logging, tracing, metrics.
> If you want to *mutate* an operation (rewrite the `where`, inject fields, add
> delegate methods, expose typed operation args), that is
> [plugin](/docs/plugins/writing-plugins) territory.
