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.
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:
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:
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: 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:
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()takespageandperPage, orlimitandskip.pagecomputesskip, so the two cannot be combined.cursor()takeslimitand one ofafterorbefore. WithoutorderByit pages by primary key ascending. When you sort by another column, end theorderBywith a unique column so pages cannot overlap or skip rows. Relation keys inorderBythrowINVALID_ARGS; usepaginate()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 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:
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 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) onerelations 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.
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
includefields or a relationselect:LOCK_NOT_SUPPORTED, "Row locks are only supported on read queries without general relation loading." - as a
lockinside nested relation args: a type error, andLOCK_NOT_SUPPORTEDat runtime for untyped callers count,exists, and writes do not acceptlock
A single one relation include on the root query, such as include: { author: true }, is supported. See row 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
targetthat 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 (datais empty;countis 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
$rawand$executeRawaccept only tagged templates or Drizzlesqlobjects$rawUnsafeis disabled unlessraw.allowUnsafeis enabled- raw calls bypass model transforms and CRUD hooks; they have their own
beforeRaw,afterRaw, andonRawErrorhooks
The rules plugin 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.