# Limitations & tradeoffs

> What better-drizzle does not do, where plugins stop applying, and where it stays native-first or explicit instead of adding silent abstractions.

URL: https://better-drizzle.com/docs/guides/limitations

better-drizzle covers the common repository calls and leaves the rest to Drizzle. The raw Drizzle `db` you passed to `better()` stays available for anything listed here.

## No `groupBy` or aggregates beyond count

Delegates expose `count()` and `exists()`, and `include: { count: { select: { ... } } }` projects relation counts. There is no `groupBy`, `sum`, `avg`, `min`, or `max`. Use the Drizzle query builder for those:

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

const stats = await db
	.select({
		authorId: posts.authorId,
		total: count(),
		avgScore: avg(posts.score),
	})
	.from(posts)
	.where(eq(posts.published, true))
	.groupBy(posts.authorId);
```

## `orderBy` takes columns and relations, not expressions

`orderBy` accepts scalar columns of the queried table as one field map or an array for multi-column ordering. A value can be `'asc'` / `'desc'`, or an object with `direction` and optional `nulls`:

```ts
await client.users.findMany({
	orderBy: { updatedAt: { direction: 'desc', nulls: 'last' } },
});
```

When `nulls` is omitted, the database default applies. Explicit null placement is supported by PostgreSQL and SQLite using their `NULLS FIRST` / `NULLS LAST` syntax; MySQL uses an equivalent `IS NULL` ordering term. Better Drizzle fails during client initialization if it cannot identify the provider's dialect.

It also accepts [relation keys](/docs/querying/reads#ordering-by-relations): fields of a `one` relation, nested to any depth, and `{ count }` for a `many` or many-to-many relation. Other aggregates (`_max`, `_min`, `_sum`, `_avg`), `count` with a `where` filter, and relation sorts combined with cursor pagination are not supported.

`orderBy` does not support SQL expressions. Use Drizzle directly for those cases:

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

const rows = await db
	.select()
	.from(posts)
	.orderBy(sql`${posts.publishedAt} desc nulls last`);
```

## Reserved names

`count` is reserved in two places. In `include`, the key `count` always means the relation count, so a relation named `count` cannot be loaded through `include`. In `orderBy`, `{ posts: { count: 'desc' } }` always means the relation count on a to-many relation, so a to-many relation named `count` cannot be sorted by its own fields. A `one` relation can still sort by a scalar column named `count`.

Rename the relation or column to avoid the collision. The `_count` alias was a deprecated way around it and is removed in 0.5; an escape hatch for the aggregate operators will be provided where one is needed.

## Pagination inputs

* `paginate()` takes `page` and `perPage`, or `limit` and `skip`. `page` computes `skip`, so the two cannot be combined.
* `cursor()` takes `limit` and one of `after` or `before`. Without `orderBy` it pages by primary key ascending. When you sort by another column, end the `orderBy` with a unique column so pages cannot overlap or skip rows. Relation keys in `orderBy` throw `INVALID_ARGS`; use `paginate()` for pages sorted by a relation.

## Transforms apply to the root query only

Plugin `transform()` runs once per operation, on the root query. Relation loads from `include` or relation `select`, and `count` projections, are built by the batched loader and do not pass through transforms, so a `where` added by a transform does not reach nested relations.

Plugins that must filter every level declare a [model scope](/docs/plugins/plugin-scopes#model-scopes) instead. Since 0.4.0, `better-drizzle/soft-delete` and `better-drizzle/tenant` are model scopes, so nested reads (include, relation select and filters, `count`, relation sorts) hide deleted rows and other tenants' rows. A custom plugin that only uses `transform()` should filter nested relations explicitly:

```ts
const users = await client.users.findMany({
	include: {
		posts: { where: { archivedAt: null } },
	},
});
```

## Soft delete coverage

`better-drizzle/soft-delete` filters every read and every update or delete with a `where`, including nested reads, `connect`, and `set` lookups, and turns `delete` / `deleteMany` into updates. The `deleted` argument controls only the root rows; nested reads always hide deleted rows. One gap remains: `upsert` and `upsertMany` are not filtered, because hiding a deleted row there would turn its update into a duplicate insert.

See [soft delete](/docs/plugins/soft-delete#what-is-filtered) for the full table.

## Relations the loader cannot use

better-drizzle reads relations from Drizzle 1.x `defineRelations(...)`, but its loader does not support every relation shape:

* relations with a relation-level `where` (filtered relations)
* `one` relations declared through a junction table

Loading one with `include` or `select` throws `Relation "x" on "t" cannot be loaded: ...`, and filtering through one in `where` throws `Relation "x" on "t" cannot be filtered: ...`. They still work with Drizzle's own relational queries on the underlying `db`. See [troubleshooting](/docs/guides/troubleshooting#a-relation-cannot-be-loaded).

## PostgreSQL-only filters and mutations

JSONB path filters and mutations, and native array filters and mutations, compile to PostgreSQL operators and fail fast on SQLite and MySQL. JSONB path filters also require a `jsonb` column: Drizzle 1.x types `json` and `jsonb` alike, so a path filter on `json` type-checks and throws at runtime.

## Row locks reject relation loading

`lock` works on PostgreSQL and MySQL for `findMany`, `findFirst`, `findOne`, `findUnique`, `paginate`, and `cursor`. It is rejected, not silently dropped, in these cases:

* on SQLite: `LOCK_NOT_SUPPORTED`
* with general relation loading, meaning multiple `include` fields or a relation `select`: `LOCK_NOT_SUPPORTED`, "Row locks are only supported on read queries without general relation loading."
* as a `lock` inside nested relation args: a type error, and `LOCK_NOT_SUPPORTED` at runtime for untyped callers
* `count`, `exists`, and writes do not accept `lock`

A single `one` relation `include` on the root query, such as `include: { author: true }`, is supported. See [row locks](/docs/advanced/locks).

## `upsertMany` is native-first

`upsertMany` compiles to one `INSERT ... ON CONFLICT` (PostgreSQL, SQLite) or `INSERT ... ON DUPLICATE KEY UPDATE` (MySQL) per batch and fails fast instead of looping through many `upsert()` calls. MySQL fires that clause on any unique key, so there it also requires:

* a `target` that is exactly the primary key or one unique key
* no other unique key the rows could match: rows cannot set its columns, and they cannot have a static default (an auto-increment id is fine)
* no `where`, and no returned rows (`data` is empty; `count` is the number of rows sent)

`upsertMany` is also a write primitive, not a graph loader: it supports `select`, but not relation `include` or relation selects.

## Some transaction options are dialect-specific

`isolationLevel` and `readOnly` are ignored on SQLite, and `comment` is passed to transaction hooks but never sent to the database. Choose how unsupported options are handled with the client's `transaction.unsupportedOptions` setting (`'warn'`, `'throw'`, or `'ignore'`) instead of discovering it by accident.

## Raw SQL is explicit, and rules cover it partially

* `$raw` and `$executeRaw` accept only tagged templates or Drizzle `sql` objects
* `$rawUnsafe` is disabled unless `raw.allowUnsafe` is enabled
* raw calls bypass model transforms and CRUD hooks; they have their own `beforeRaw`, `afterRaw`, and `onRawError` hooks

The [rules plugin](/docs/plugins/rules) checks `$raw`, `$rawUnsafe`, and `$executeRaw` through `beforeRaw`. CRUD rules such as `noDeleteManyWithoutWhere` never inspect raw SQL, so a raw `DELETE` is only caught by raw rules like `noRawMutation`.

## Plugins are the mutation layer

Client hooks are side-effect oriented. Plugins are the place for behavior that rewrites operations or extends APIs.

That separation is intentional so application code and plugin code do not collapse into one giant interception layer.

## No codegen layer

The project reads your Drizzle schema directly. That keeps setup small, but it also means:

* your schema remains the source of truth
* relation quality depends on the relations you define in Drizzle
* better-drizzle is not trying to invent a second modeling system

## Performance claims are parity-shaped

The benchmark suite separates fair API-parity comparisons from lower-level manual Drizzle reference queries, and overhead numbers come only from the parity group. See [benchmark parity](/docs/performance/parity).
