Drizzle ORM, without the glue
Relation filters, nested includes, pagination, relation writes, and plugins on every table. Fully typed, on top of the Drizzle client you already have.
$ npm i better-drizzle drizzle-orm@1.0.0-rc.4import { better } from 'better-drizzle';
import { softDelete } from 'better-drizzle/soft-delete';
// your drizzle({ relations }) instance, one plugin
const client = better(db, { plugins: [softDelete()] });
// top 10 authors by post count, each with their 3 latest posts
const authors = await client.users.findMany({
where: { posts: { some: { published: true } } },
orderBy: { posts: { _count: 'desc' } },
include: {
posts: { orderBy: { createdAt: 'desc' }, take: 3 },
_count: { select: { posts: true } },
},
take: 10,
});
// deleted users and posts are skipped at every level:
// the filter, the sort, the include, and the count
await client.posts.delete({ where: { id } }); // sets deletedAtThe same query, without the glue
A paginated feed with a relation filter, author data, and comment counts. Same result, fully typed on both sides.
import { and, count, desc, eq, getColumns, sql } from 'drizzle-orm';
const where = and(eq(posts.published, true), eq(users.active, true));
const [data, [{ total }]] = await Promise.all([
db
.select({
...getColumns(posts),
author: { name: users.name },
_count: {
comments: sql<number>`(
select count(*) from ${comments}
where ${comments.postId} = ${posts.id}
)`.mapWith(Number),
},
})
.from(posts)
.innerJoin(users, eq(users.id, posts.authorId))
.where(where)
.orderBy(desc(posts.createdAt))
.limit(20)
.offset(20),
db
.select({ total: count() })
.from(posts)
.innerJoin(users, eq(users.id, posts.authorId))
.where(where),
]);
const pageCount = Math.ceil(total / 20);
const pagination = {
page: 2,
perPage: 20,
total,
pageCount,
hasNext: 2 < pageCount,
hasPrevious: true,
};const { data, pagination } = await client.posts.paginate({
where: {
published: true,
author: { is: { active: true } },
},
include: {
author: { select: { name: true } },
_count: { select: { comments: true } },
},
orderBy: { createdAt: 'desc' },
page: 2,
perPage: 20,
});Everything you rewrite, once
A consistent repository API per table - the patterns every service ends up re-implementing, generated from your schema and kept typed.
Relations without N+1
Nested include and select load any depth with one query per relation node, per-parent take/skip, and _count totals in the same SQL. About 9x faster than Drizzle's own relational queries.
Filter through relations
posts: { some: { published: true } } - some, every, none, and is, typed from your Drizzle relations. JSONB paths and array operators on PostgreSQL. No subqueries by hand.
Nested writes
connect, disconnect, and set relations inside create, update, and upsert, many-to-many included. The whole write runs in one transaction automatically.
Bulk writes in one statement
upsertMany as one native upsert, updateEach as a single UPDATE ... CASE, createMany with skipDuplicates and batching. No loops sending one query per row.
Atomic updates
increment, decrement, multiply, and toggle without reading first. Append to PostgreSQL arrays or set one JSONB path with jsonb_set, all typed.
Pagination, done
paginate() returns total, pageCount, hasNext; cursor() returns next and previous cursors for feeds. Same { data, pagination } shape for both, no metadata math by hand.
Prepared reads
Mark values with param() and call .prepare() on any read. Plugins and hooks set up once, then every execute() reuses the same statement with new values.
Transactions that hold up
Nested savepoints, afterCommit / afterRollback callbacks, and automatic retries on deadlocks and serialization failures.
Plugins in the box
Soft delete, timestamps, Zod schemas, an experimental read cache, and runtime guardrails ship in the same package. Write your own with typed args and hooks.
How it compares
Prisma-style calls on the Drizzle schema you already have. Raw Drizzle stays available for the queries that need SQL.
| better-drizzle | Drizzle | Prisma | |
|---|---|---|---|
| Schema | Your Drizzle tables | TypeScript tables | schema.prisma + generate |
| Nested relations | include / select, batched | db.query or manual joins | include / select |
| Relation filters | some, every, none, is | exists by hand | some, every, none, is |
| Pagination metadata | paginate(), cursor() | by hand | skip / take + count |
| Relation writes | connect, disconnect, set | junction rows by hand | nested writes |
| Bulk upserts | upsertMany, updateEach | insert builders | loop of upserts |
| Raw SQL | $raw + Drizzle, same connection | sql everywhere | $queryRaw |
| Migrations | drizzle-kit | drizzle-kit | prisma migrate |
Faster where it counts. Close everywhere else.
Every number comes from API-parity benchmarks: raw Drizzle does the same work and returns the same shape. The batched relation loader beats Drizzle’s own relational queries by an order of magnitude, and the wrapper costs a few microseconds on everything else.
- Median read overhead
- ~5%
- Write overhead
- < 5%
- Prepared read overhead
- < 4%
Loading users with posts and comments
9.5× faster
SQLite in-memory, same machine, both sides interleaved. See every benchmark →
Works with your existing database
The dialect is read from your Drizzle instance. Keep your driver, keep your schema - the same client API runs on all three.
Compare dialect supportPostgreSQL
The full API, plus typed JSONB path filters and updates, native array operators, ILIKE, and row locks.
MySQL
The full repository API, with native ON DUPLICATE KEY upserts and row locks. Batch writes return counts.
SQLite
The full API with RETURNING. A fast fit for local development, tests, and in-memory databases.
Plugins that know your schema
Plugins see every query and every relation. The cache knows which reads a write affects, even through an include, so you never manage keys.
const client = better(db, {
plugins: [cache({ store: redis({ client: new Redis() }), ttl: '5m' })],
});
const feed = await client.posts.paginate({
where: { published: true },
include: { author: true },
page: 1,
perPage: 20,
cache: true, // Redis after the first call
});
await client.posts.update({ where: { id }, data: { title } });
await client.users.update({ where: { id: authorId }, data: { name } });
// both writes invalidate the cached feed - no keys to trackSoft delete
delete() sets deletedAt. Reads skip deleted rows.
Timestamps
createdAt and updatedAt filled on every write.
Zod
A schema per table, checked before each write.
Cache
Cached reads, cleared when the rows change. Experimental.
Rules
Stop unbounded reads and unsafe writes at runtime.
ESLint
The same rules, flagged in your editor.
Need something else? Write your own plugin →
Works with your agent
Install the skill once. Your agent reads the current docs and writes the real API.
- Claude Code
- Codex
- Cursor
- Copilot
- Windsurf
- ChatGPT
$ npx skills add almeidazs/better-drizzleA skill for coding agents
One command installs it in Claude Code, Codex, Cursor, Copilot, and other agents. It checks your installed version before it writes code.
Docs as Markdown
llms.txt, llms-full.txt, and a .md version of every page. Copy a page as Markdown or open it in ChatGPT or Claude.
Guardrails that catch mistakes
The ESLint plugin flags unbounded reads and unsafe writes in the editor, and the rules plugin enforces them at runtime.
What developers are saying
Nick Venturi
@nickvnturi
finally i can stop copy pasting the same pagination helper into every new project
Rohit Kashyap
@rohit_jsfreaky
wrap without replacing plus keep the raw sql escape hatch is the right call. most convenience layers die because they hide the tool underneath, so the day you hit a weird query you are stuck. leaving the door open is what makes a wrapper safe to adopt
Savvas Papageorgiadis
@papsavas
Were wishing for something like this for a looong time
Marc
@kumiko_rocks
request scoped context is the unlock for multi-tenant too. set the tenant once per request, every query hook reads the same context, and isolation stops being something each dev remembers on every query. exactly the glue you kept rewriting. nice work
blemish
@blemish8651
This is sick asf Solution to the problems I had with drizzle too
Matin Tat
@werdoxdev
So does this solve the typing problem for when creating wrappers on drizzle stuff and you want to pass a custom "select" property? And still have it fully typed? If yes I'm switching today.
niloy
@nil_ooy
Having .explain() right on the query is the bit I like here. Less boilerplate is great; keeping the query plan within reach makes the abstraction easier to trust.
Mark Lyck
@MarkLyck
@DrizzleORM add this to the main ORM
Marc Maceira
@marcmaceira
Neat! Will be testing it out. It's definitely one of those things that keeps repeating for every project.
Sérgio Carneiro
@sergioccarneiro
This would be much-welcome in Drizzle itself
bushcubed
@bushcubed
typed json is sick
Binary Sniper
@MrBinarySniper
Wow. I already Love it.
Questions developers ask
Straight from the replies on X. Something else? Open an issue.
Why not just use Prisma?
You keep Drizzle: a TypeScript schema, SQL-first queries, no codegen, and no query engine. better-drizzle adds the repository-style API on top, and the raw Drizzle client stays available for anything it does not cover. Compare with Prisma.
Doesn't Drizzle already have db.query?
It does, for reads. better-drizzle gives every table one API for reads and writes: count, exists, paginate, and cursor, relation writes, bulk upserts, atomic updates, hooks, plugins, and transactions with retries. Its relation loader is also about 9x faster than db.query on nested reads.
Does it hide the SQL?
No. Every read has .explain(), which returns the generated SQL, its params, and the database’s plan. Raw SQL is one call away with $raw, and the Drizzle instance you wrapped keeps working next to it.
How much overhead does it add?
Around 5% on reads and under 5% on writes, measured against Drizzle doing the same work. Relation graphs are faster through the wrapper. See the benchmarks.
Which Drizzle versions are supported?
better-drizzle 0.3 runs on Drizzle ORM 1.0 RC (drizzle-orm@1.0.0-rc.4). Projects still on Drizzle 0.x can use better-drizzle 0.2. Upgrade guide.
Is it ready for production?
The core API is stable within 0.3.x. Breaking changes land in minor releases with an upgrade guide, and anything experimental, like the cache plugin, is marked as such. Stability policy.
better-drizzle is free and open source, kept going by
Using it at work? Become a sponsor and put your logo here.