Why better-drizzle?
What you gain over raw Drizzle, what stays exactly the same, and the few cases where raw Drizzle is still the right tool.
Drizzle is an excellent SQL builder. Application code, though, asks the same questions over and over: load this row with its relations, give me page 2 with totals, update these rows in one statement, run all of it in a transaction. With raw Drizzle, every one of those is glue you write by hand.
better-drizzle generates a repository API from the Drizzle schema you already have. It adds no schema language, no codegen step, and no second connection. Queries compile to Drizzle and run on your driver, and raw Drizzle stays one line away.
The same feature, both ways
A typical endpoint: page 2 of active users, 20 per page, each with their 3 latest published posts and their total post count.
const page = await client.users.paginate({
where: { active: true },
orderBy: { name: 'asc' },
limit: 20,
skip: 20,
include: {
posts: {
where: { published: true },
orderBy: { id: 'desc' },
take: 3,
select: { id: true, title: true },
},
_count: { select: { posts: true } },
},
});
// { data: [...], pagination: { page, perPage, total, pageCount, hasNext, hasPrevious } }Both versions are fully typed. In the raw version, though, the filters are written twice, the join keys are managed by hand, and a code reviewer has to check the pagination math. The better-drizzle call infers its result type from the include and select you wrote. It has the same shape in every endpoint, and the relation paths come from your schema, not from hand-written joins.
What you gain
Relations without join glue
includeandselectload related rows at any depth. Each level accepts its ownwhere,orderBy,take,skip, andcursor, and per-parent limits run in SQL.- Many-to-many relations are inferred from simple junction tables.
_countadds relation totals as a subquery in the same statement, with no extra round-trip.- Relation filters (
some,every,none,is,isNot) replace hand-builtexistssubqueries. - The batched loader issues one query per relation node. On the relation graph benchmark it is ~9× faster than the equivalent hand-written code.
See Relations.
Reads that answer the real question
findUnique,findFirst,count, andexistscover the lookups you write every day, with no[0]unwrapping.paginate()andcursor()return{ data, pagination }with exact metadata, including correcthasNext/hasPreviouson cursor pages..throw()turns a nullable result into an error when the row is required..explain()shows the query plan on PostgreSQL, MySQL, and SQLite without running the query.
Writes that use the database, not loops
upsertuses nativeON CONFLICTwhen it can, not a read followed by a write.upsertManydoes batched native upserts.updateEachupdates many rows with different values in oneCASEstatement.createManyhasskipDuplicates.connect,disconnect, andsetattach and detach related rows inside an implicit transaction.
See Create, update & delete and Relation writes.
Transactions you don't have to babysit
- Nested transactions become savepoints automatically, even on Bun SQLite, whose native Drizzle transaction callback is synchronous.
- Transactions support retries, explicit
rollback(), andafterCommit/afterRollbackcallbacks. - The
txclient passed to the callback is a full better-drizzle client: delegates, raw SQL, and plugins all run inside the transaction.
See Transactions.
Errors you can branch on
Database failures are normalized into a BetterDrizzleError with a stable code, plus table, column, and constraint when the driver reports them. Unique violations and foreign key failures look the same on every dialect. See Error handling.
Cross-cutting behavior in one place
metaand$withContext()carry request and tenant context into every query and hook.- Hooks cover auditing, tracing, and metrics.
- Official plugins handle timestamps, soft delete, Zod validation, and runtime guardrails, such as blocking an unfiltered
deleteMany. The ESLint plugin runs the static subset of those checks in your editor.
Near-zero cost
The wrapper is benchmarked against raw Drizzle doing the same work. Reads land within ~9%, writes within ~5%, and relation loading is faster. See Benchmarks for how the numbers are measured.
What stays exactly the same
- Your schema. Tables and relations stay plain Drizzle.
- Your driver. PostgreSQL, MySQL, or SQLite; the dialect is detected.
- Your escape hatches. Pass a Drizzle
sqlfragment straight intowhere, or use$raw/$executeRaw. - Your existing code. better-drizzle wraps your Drizzle client, so current queries keep working. You can migrate one call site at a time.
When to reach for raw Drizzle
The delegate API models the queries applications repeat. A handful of query shapes are better written as SQL, and for those raw Drizzle is the right tool:
- Reporting and analytics.
GROUP BYwith aggregates, window functions over the result, and CTEs. The delegate API hascount, but nogroupByorsum. - Set-based statements.
INSERT ... SELECT,UPDATE ... FROM, and other statements that move data between tables in one pass. - Dialect-specific features the API does not model. Full-text search ranking, custom operators, or extension functions, when they go beyond a
sqlfragment insidewhere. - A measured hot path. Parity overhead tops out around 11%, on read-only transactions. When a profiler shows that margin matters, hand-tune that one query.
It is not all-or-nothing
The Better client and your Drizzle db share the same connection. Inside
client.transaction(...), tx.$raw and tx.$executeRaw run in the same
transaction as your repository calls. Use the delegate API by default and
drop to SQL only for the query that needs it.
Compared to a full ORM
If you have used Prisma, the delegate API will feel familiar: findMany, where, include, select, upsert. The difference is that better-drizzle derives that surface from your Drizzle schema at the type level and compiles to Drizzle queries. There is no separate schema file, no generate step, and no query engine process. You keep Drizzle's model and SQL-first control, and gain the ergonomics.