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 is column and direction only
orderBy accepts scalar columns of the queried table, each with 'asc' or 'desc', as one object or an array for multi-column ordering. It does not support:
NULLS FIRST/NULLS LAST, so null placement is the database default (for example, PostgreSQL puts nulls last in ascending order, SQLite and MySQL put them first)- SQL expressions
- ordering the root rows by a relation's columns
For those, use Drizzle directly:
import { sql } from 'drizzle-orm';
const rows = await db
.select()
.from(posts)
.orderBy(sql`${posts.publishedAt} desc nulls last`);Pagination inputs
paginate()takeslimitandskip. There is nopageorperPageinput; computeskipas(page - 1) * limit. The response still reportspage,perPage,total, andpageCount.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.
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. A plugin that adds a where condition, such as tenant scoping or soft-delete visibility, does not add it to nested relations.
Filter nested relations explicitly when it matters:
const users = await client.users.findMany({
include: {
posts: { where: { deletedAt: null } },
},
});Soft delete coverage
better-drizzle/soft-delete hides deleted rows only in findMany, findFirst, count, and exists, and rewrites only delete into an update. Everything else sees and changes deleted rows as ordinary rows:
| Operation | Behavior with the plugin |
|---|---|
findMany, findFirst, count, exists | deleted rows hidden by default; deleted: 'with' | 'only' changes it |
findUnique, findOne | not filtered, returns a deleted row |
paginate, cursor | not filtered, includes deleted rows in data and total |
relations in include / select, _count | not filtered, see above |
delete | soft delete by default; mode: 'hard' deletes physically |
deleteMany | physical delete, not rewritten |
update, updateMany, updateEach, upsert, upsertMany | not filtered, can modify deleted rows |
Add deletedAt: null to the where of these calls when they must ignore deleted rows. The rules plugin rule noHardDeleteOnSoftDeleteModel only checks delete({ mode: 'hard' }); it does not flag deleteMany.
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 a nested relation:LOCK_NOT_SUPPORTED 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
- supported on PostgreSQL and SQLite
- currently unsupported on MySQL
- fails fast on unsupported dialects instead of looping through many
upsert()calls
A slow hidden fallback would make throughput and behavior harder to reason about.
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 PostgreSQL only. 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.