better-drizzle 0.3 now runs on Drizzle ORM 1.0 RC. Read the upgrade guide

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.4
authors.ts
import { 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 deletedAt

The same query, without the glue

A paginated feed with a relation filter, author data, and comment counts. Same result, fully typed on both sides.

Drizzle2 queries, join, subquery, page math
feed.ts
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,
};
better-drizzleone call, same result
feed.ts
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-drizzleDrizzlePrisma
SchemaYour Drizzle tablesTypeScript tablesschema.prisma + generate
Nested relationsinclude / select, batcheddb.query or manual joinsinclude / select
Relation filterssome, every, none, isexists by handsome, every, none, is
Pagination metadatapaginate(), cursor()by handskip / take + count
Relation writesconnect, disconnect, setjunction rows by handnested writes
Bulk upsertsupsertMany, updateEachinsert buildersloop of upserts
Raw SQL$raw + Drizzle, same connectionsql everywhere$queryRaw
Migrationsdrizzle-kitdrizzle-kitprisma 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

Drizzle db.query3.94 ms
better-drizzle414 µs

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 support

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 track

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-drizzle

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.