Type-safe repository helpers for Drizzle ORM.
Keep Drizzle's type-safety. Drop the query glue you rewrite in every service.
Sponsored by Neon
Neon is the serverless Postgres platform built for modern developer workflows.
const client = better(drizzle({ client: pool, relations }));
const users = await client.users.findMany({
where: { posts: { some: { published: true } } },
include: {
posts: { where: { published: true }, take: 3 },
_count: { select: { posts: true } },
},
});A nested relation filter, three posts per user, and a relation count - typed end to end from your Drizzle schema and defineRelations(...) config. One query per relation node, never one per row.
No codegen. No client process. No new schema language. It is still your Drizzle client underneath, and you can drop back to it at any line.
npm install better-drizzle drizzle-orm@1.0.0-rc.4Important
better-drizzle supports only Drizzle ORM 1.x (drizzle-orm >=1.0.0-rc.4 <1.0.0-rc.5; later release candidates are not supported yet) and its defineRelations(...) API. drizzle-orm 0.x is not supported - projects on 0.x must stay on better-drizzle 0.2.x. Install drizzle-orm with the explicit version: until Drizzle 1.0 is tagged latest, a plain install resolves to 0.x. See upgrading.
Tables live in your schema, relations are declared with Drizzle's defineRelations, and the Drizzle instance receives them. better() reads tables and relations from that instance.
// schema.ts
import { integer, pgTable, text } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: integer().primaryKey(),
email: text().notNull(),
});
export const posts = pgTable('posts', {
id: integer().primaryKey(),
userId: integer('user_id').notNull().references(() => users.id),
title: text().notNull(),
});
// relations.ts
import { defineRelations } from 'drizzle-orm';
import * as schema from './schema';
export const relations = defineRelations(schema, (r) => ({
users: { posts: r.many.posts() },
posts: { author: r.one.users({ from: r.posts.userId, to: r.users.id }) },
}));
// db.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { better } from 'better-drizzle';
import { relations } from './relations';
export const client = better(drizzle({ connection: process.env.DATABASE_URL!, relations }));With no relations, pass defineRelations(schema) without a callback.
| Raw Drizzle | better-drizzle | |
|---|---|---|
| Point lookups | db.select().from().where(eq(...)) + unwrap |
findUnique({ where: { email } }) |
| Relation loading | manual joins, or db.query config |
include / select, payload inferred |
| Nested relation filters | hand-built exists subqueries |
some / every / none / is |
| Pagination | rebuild metadata and cursors every time | paginate() / cursor() → { data, pagination } |
| Not-found handling | check for undefined everywhere |
nullable result or .throw() |
| Cross-cutting concerns | sprinkled through call sites | hooks and plugins |
| Timestamps / soft delete | repeated in every write | official plugins |
Describe the link. It resolves the rows and runs the writes in one transaction.
await client.posts.create({
data: {
title: 'Hello',
author: { connect: { email: 'alice@example.com' } },
},
});connect, disconnect, and set work on create, update, and both branches of upsert.
Many-to-many is declared once in defineRelations with .through(), and after that you never name the junction in a query:
// relations.ts
users: {
groups: r.many.groups({
from: r.users.id.through(r.memberships.userId),
to: r.groups.id.through(r.memberships.groupId),
}),
},const users = await client.users.findMany({
include: { groups: true },
});const page = await client.users.paginate({
limit: 20,
skip: 40,
orderBy: [{ id: 'asc' }],
where: { active: true },
});page.pagination carries total, pageCount, hasNext, and hasPrevious. Use cursor() instead for feed-style navigation and you get nextCursor and previousCursor computed for you.
orderBy accepts a field map or an array. Specify { direction, nulls } when NULL placement matters: orderBy: { lastSeenAt: { direction: 'desc', nulls: 'last' } }.
Operations that can legitimately match nothing say so in the type - and let you opt into throwing when it is genuinely exceptional.
const user = await client.users.findUnique({ where: { id } });
// ^? User | null
const user = await client.users.findUnique({ where: { id } }).throw();
// ^? UserDefine the read once with param(), then execute it with new values. Each param takes its type from the column or option it stands in for, and the result keeps the read's shape.
import { param } from 'better-drizzle';
const findUserByEmail = client.users
.findUnique({ where: { email: param('email') } })
.prepare('users.by-email');
const user = await findUserByEmail.execute({ email: 'user@example.com' });
// ^? User | null execute({ email: 1 }) is a type errorEvery read can be prepared, including paginate() and cursor(). Plugin transforms, before hooks, and beforeQuery run once at prepare time; afterQuery, intercepts (including the cache plugin), and plugin after hooks run on every execution. See prepared statements.
Declare the shape with Drizzle's $type<T>() and every scalar leaf becomes a typed dot path. PostgreSQL.
const referrals = await client.accounts.findMany({
where: {
settings: { json: { referrer: { endsWith: '@acme.com' } } },
},
});Paths and values are bound parameters, and the generated predicate is guarded by jsonb_typeof, so one row with the wrong type cannot break the cast.
The same dot paths work for partial updates through jsonb_set, leaving the rest of the document untouched:
await client.accounts.update({
where: { id },
data: { settings: { 'plan.tier': 'pro' } },
});On typed JSONB columns, dotted paths and the { json: ... } wrapper both check paths and values against $type<T>(); use the wrapper for single-level keys. Path updates create missing object ancestors, treat SQL NULL and non-object JSONB roots as {}, and preserve existing object ancestors and unrelated keys. A scalar, array, or JSON null at an intermediate path is replaced with {}. Duplicate or ancestor/descendant paths are rejected, as are values containing nested undefined. Untyped JSONB columns keep open path names and JSON-encodable values.
const rows = await db
.select()
.from(users)
.where(client.users.$where({ age: { gte: 18 }, posts: { some: { published: true } } }));$where() compiles the same typed where as findMany into a Drizzle SQL condition (undefined when empty), for joins, subqueries, and hand-written queries. It is pure compilation: plugin filters such as soft-delete visibility are not applied.
const users = await client.users.findMany({
where: { active: true },
lock: {
mode: 'update',
skipLocked: true,
},
});PostgreSQL and MySQL. SQLite fails fast instead of silently dropping the lock, and locks: { transactionsOnly: true } enforces that locked reads only run inside a transaction.
The callback receives a full client bound to the transaction, so delegates, plugins, hooks, and nested savepoints all keep working.
await client.transaction(async (tx) => {
const user = await tx.users.create({
data: { name: 'Alice', email: 'alice@example.com' },
});
tx.afterCommit(() => sendWelcomeEmail(user.email));
});Also available: automatic retries on deadlock and serialization failures, afterRollback, and isolation levels where the dialect supports them.
Package setup, transforms, and typed extensions once, instead of wrapping better(...) yourself.
import { recommended, rules } from 'better-drizzle/rules';
import { softDelete } from 'better-drizzle/soft-delete';
import { timestamps } from 'better-drizzle/timestamps';
import { zod } from 'better-drizzle/zod';
const client = better(db, {
plugins: [rules(recommended()), timestamps(), softDelete(), zod()],
});That gets you runtime guardrails, automatic timestamps, soft deletes with restore(), and Zod schemas generated from your tables at client.users.$zod. Plugins can add their own typed operation args, so client.users.findMany({ deleted: 'only' }) type-checks.
Pair better-drizzle/eslint with the runtime rules to catch the statically-checkable subset in your editor.
better-drizzle/ata (experimental) validates the same operations against compiled JSON Schema, as a faster alternative to the Zod plugin. See ata.
Warning
better-drizzle/cache is experimental in 0.3.x: its options, $cache API, store interface, and entry format can change in a patch release.
better-drizzle/cache caches the reads you opt into, and better-drizzle/cache/redis stores them in the Redis client you already have. Observed writes invalidate declared dependencies after they commit, including included relations and declared foreign-key cascades. Commit and invalidation are separate operations; concurrent reads and store failures can leave stale results cached, so use database reads when consistency must be exact. No Redis key is ever scanned. Raw Drizzle writes still need a manual $cache.invalidate().
import { cache } from 'better-drizzle/cache';
import { redis } from 'better-drizzle/cache/redis';
const client = better(db, {
plugins: [cache({ store: redis({ client: redisClient }), ttl: '5m' })],
});
const user = await client.users.findUnique({ where: { id }, cache: true });Measured against raw Drizzle doing the same work and returning the same shape - not against a lower-level query that does less.
- 9.1× faster relation loading (10.24 ms → 1.12 ms), and the gap widens with the number of parent rows
- every other read within ~9%, writes within ~5%
- prepared reads within ~4% of Drizzle's own prepared statements, and 1.5–3.1× faster than the same read unprepared
- zero runtime dependencies
Relation loading wins because the batched loader issues one query per relation node instead of the per-row work the equivalent hand-written code ends up doing. Numbers are SQLite in-memory to isolate wrapper overhead from I/O; reproduce them with bun run bench:report.
Full tables, methodology, and the cases where the wrapper costs you: benchmarks.
.explain() on any read without running it · updateEach for per-row batch updates in one statement · upsertMany · $withContext for request-scoped metadata · extends() for your own helpers · raw SQL with its own hooks · lifecycle hooks for auditing and tracing.
Atomic updates (increment, decrement, multiply, divide, toggle) · typed PostgreSQL array filters and mutations.
better-drizzle ships a first-party skill pack - SKILL.md plus task references - for coding agents that need accurate API guidance and review guardrails. Zero scripts, zero network. See the AI docs.
Getting started · Querying · Writing · Plugins · Why better-drizzle? · Limitations