A Wrapper Is a Bug Report
There is a folder in this site's API called repositories/postgres. Fifteen files, 3,575 lines. Open any two of them side by side and you are reading the same file twice: list with optional filters, count with those same filters pasted in again, create, update, delete. I wrote all of it by hand on top of Drizzle, an ORM whose whole pitch is that it is type-safe. That folder holds thirty as any casts.
Here is one, in the most ordinary thing a backend does, a list endpoint with two optional filters:
let query = db.select().from(projects);
const conditions = [];
if (featured !== undefined) {
conditions.push(eq(projects.featured, featured));
}
if (search) {
conditions.push(
or(
ilike(projects.title, `%${search}%`),
ilike(projects.description, `%${search}%`)
)!
);
}
if (conditions.length > 0) {
query = query.where(and(...conditions)) as any;
}
const result = await query.limit(limit).offset(offset);The cast is there because the builder's type changes when you call .where(), and I wanted to add it conditionally. So the types I was promised go out of the window at the exact moment the query stops being trivial. Twenty lines further down, countProjects repeats that whole conditions block verbatim, because a count needs the same filters and there is nowhere to put them once. If I add a third filter and forget the second copy, the page count silently disagrees with the page.
The question I keep circling: is that folder my fault, or Drizzle's?
Drizzle's answer is that it's my fault#
Drizzle is unusually direct about this, which I respect. Its overview says "If you know SQL, you know Drizzle", and then draws a line that most libraries leave implicit:
First and foremost, Drizzle is a library and a collection of complementary opt-in tools. [...] we call them data frameworks. With data frameworks you have to build projects around them and not with them.
Read that honestly and my folder is the intended outcome. Drizzle hands you SQL with types on it and stops. The repository layer is mine to write, and mine to get wrong. Complaining about it is close to complaining that a hammer is not a house.
Two more principles hold up that position. "The best part is no part. Drizzle has exactly 0 dependencies", and a performance promise that turns out to matter more than it looks: "Drizzle always outputs exactly 1 SQL query."
What Better Drizzle is#
Better Drizzle is a library that fills that box for you. You hand it the Drizzle client and schema you already have, and it gives every table a repository:
import { better } from 'better-drizzle';
const client = better(db, { schema });From there you get findMany, findFirst, create, update, upsert, delete, count, exists and paginate on every table, without codegen and without a build step. It reads the schema at runtime. The querying is object-shaped rather than builder-shaped, which is precisely what my as any was fighting:
const rows = await client.posts.findMany({
where: {
published: true,
author: { is: { active: true } },
},
select: {
id: true,
title: true,
author: { select: { id: true, name: true } },
},
orderBy: [{ id: 'desc' }],
take: 20,
});Optional filters are now just optional keys on an object. No reassignment, so no cast. paginate returns { data, pagination } with count, hasNext and hasPrevious, which is the thing I re-derive in fifteen files. There are lifecycle hooks for the cross-cutting work, transactions with real savepoints and afterCommit callbacks, and two official plugins: @better-drizzle/timestamps and @better-drizzle/soft-delete.
That timestamps plugin is worth pausing on. My schema declares two dozen timestamp columns, and my repositories hand-write updatedAt: new Date() on update twenty-one times. Twenty-one chances to forget, and the failure mode is a row that lies about when it changed. That is not a hard problem. It is just a problem nobody should be solving per-file.
If this all reads like Prisma, that is deliberate, and Better Drizzle says so in its own comparison page: the delegate API will feel familiar if you have used Prisma, the difference being that it is generated from your Drizzle schema, with no separate schema language and no client process.
The core already agreed with itself#
Here is the part that changed my mind while writing this.
Drizzle already ships an ergonomic layer. db.query.users.findMany() is a repository API with where, with and columns, sitting in core. The philosophy says "we are a library, build your own layer", and then the library ships half of that layer anyway. Read that as an admission rather than hypocrisy: the relational query builder exists because people kept writing it themselves.
For years it was a crippled admission. You could not filter parents by a child's column. There was no way to ask "give me users who have at least one published post" through db.query, so you dropped to a manual join, which is exactly where my casts live. Issue #1069 asked for it back, because the feature had been removed for performance. The top comment in the thread is the whole tension in one line: what is the point of performance if you have to throw away correctness to get it.
"Exactly 1 SQL query" is a real engineering promise, and it is also the reason that feature was cut. The bill came to two and a half years.
It is fixed now. #1069 closed as completed on 3 January 2026, tagged implemented-in-beta, and the relational queries API in the v1 line filters parents by relation:
const usersWithPosts = await db.query.usersTable.findMany({
where: {
id: {
gt: 10
},
posts: {
content: {
like: 'M%'
}
}
},
});Object-shaped filters. Relation filters. Operators as keys. That is Better Drizzle's read half, in core, written by the Drizzle team. My argument stopped being "Drizzle should do this" the moment I read that closed issue, and became something narrower: Drizzle already started doing this, and stopped halfway.
Two caveats keep this honest. Drizzle v1 is not out. The latest stable release on npm is 0.45.2 from March, and v1 is on 1.0.0-rc.4. And the two ecosystems picked different words for the same idea: Better Drizzle took Prisma's some, every, none and is, while Drizzle's own v1 filters by nesting the relation name directly under where. Same concept, two vocabularies, and a lot of blog posts about to be wrong.
Where the correlation died#
I did not intend to find this. I was reading Better Drizzle's relation filters to write the section above, the generated SQL looked wrong, so I set up three users on Postgres: Alice with one published post, Bob with one draft, Carol with nothing. Three questions that should have three different answers. Reproduced on Better Drizzle 0.1.1 against Postgres 17.10:
some: at least one published post
expected : ["Alice"]
got : ["Alice","Bob","Carol"]
SQL : select "id", "name" from "users"
where exists (select 1 from "posts" where "posts"."published" = $1)The subquery never mentions Alice. There is no and "posts"."author_id" = "users"."id", so exists asks "does any published post exist anywhere", which is true, so every user comes back. none and every fail the same way in the other direction, returning nobody. Three filters, three wrong answers, no error. It does the same thing on SQLite.
The cause is four lines in compiler.ts:
const referencedColumns = getTableColumns(referencedTable);
const referencedColumn = referencedColumns[reference.name];
if (referencedColumn)
conditions.push(eq(referencedColumn, sourceField));getTableColumns() returns a map keyed by JavaScript property name, so authorId. But reference.name is the database column name, so author_id. The lookup misses, the if quietly skips the condition, and and() of zero conditions returns undefined, which Drizzle drops from the query. The correlation does not fail loudly. It evaporates.
Two details make this more than a stray typo, and both point the same way.
It only breaks in one direction. is and isNot work fine, because a to-one filter resolves the primary key, id: integer('id'), where the two names happen to match. The to-many case resolves a foreign key, authorId: integer('author_id'), where they do not. The bug hides exactly where a normal schema puts snake_case, which is to say the schema in Better Drizzle's own docs.
The tests could not catch it. The two to-many tests assert length > 0 and length >= 0. The first passes when all five fixture users come back wrongly. The second cannot fail at all. There was no every test. The fixture data was already good enough to expose everything, it just was not asserted against.
I reported it as issue #26 and sent PR #27, which resolves the column by database name, falls back to the reference that Drizzle already resolved, and replaces those tests with ones that name the expected users. Both were open as I published this.
Here is why it belongs in this post rather than in a different one. This is not a story about a careless author. The API is good, the docs are good, and the CI is better than most three-week-old projects have. The bug is in the seam: metadata that Drizzle owns, re-derived from outside by a library that has to guess how Drizzle keys its column maps. A repository layer inside core would read that map from the same side that wrote it. Every wrapper in this category is one internal rename away from the same class of failure, and no amount of care fixes that, because the wrapper is standing on the wrong side of the information.
What's actually still missing#
The read half landed in v1. The rest of my folder has nowhere to go.
There is a second gap underneath that one. drizzle.config.ts is titled "Drizzle Kit configuration file", and it means it. All thirteen keys are for the CLI: generate, push, introspect, studio. Nothing there is read at runtime. So even if Drizzle wanted plugins tomorrow, there is currently no place to declare them once for your application. better(db, { schema, plugins }) is the runtime configuration Drizzle does not have.
Where this argument is weakest#
I should argue against myself, because there is a real case on the other side.
Drizzle's thinness is load-bearing, not laziness. Zero dependencies and one query per call are why it runs well on serverless and why you can predict its SQL. A plugin system is a permanent maintenance surface and a governance problem, and every hook is a place where "exactly 1 SQL query" becomes a promise someone else can break. The team cut nested filters once already to keep that promise.
I might be asking Drizzle to become the thing it defined itself against. Prisma already exists. If I want where: { author: { is: {} } } and plugins and hooks, that is a coherent product and I know where it lives. "Drizzle should absorb this" is one short step from "Drizzle should be Prisma", and Drizzle is popular precisely because it is not.
Better Drizzle is early, and I would not ship it yet. Two npm releases, both from 27 June. The repo is three weeks old. It is effectively one person. Its stability page says as much. The declared peer range is drizzle-orm: ^0.30.0, which excludes the 0.45.2 that everyone is actually on, so npm install fails on the dependency resolver. And its flagship filters returned wrong rows until this week.
The benchmarks claim more than they show. The landing page advertises a read latency overhead of 0 to 18% at parity. The project's own benchmark table then lists a filtered list at plus 28.2%, outside the range printed above it. The headline 85% heap saving on reads is heap retained after a forced garbage collection, measured across 2,000 iterations: 2.20 MB against 336 KB, which is roughly 1.1 KB versus 0.17 KB per operation, and not the steady-state saving most people will read it as. Every number is SQLite in memory on one Ryzen laptop, with no Postgres figures, no repetitions and no variance. To their credit, transaction overhead is disclosed and it is bad, plus 82% heap. To be fair to a three-week-old project, publishing runnable benchmarks at all puts it ahead of most.
So the fair version of my thesis is not "Drizzle is wrong". It is that "build your own layer" was a reasonable answer when the layer was small, and the layer is not small any more. It is fifteen files in a personal blog's API.
The report that was already filed#
Better Drizzle is a good library with a bug in it, and I sent a patch. That is the boring, useful part of the week.
The more interesting part is what it is evidence for. Nobody writes a repository layer for someone else's ORM because they enjoy it. They write it because they wrote it at work, then wrote it again at the next job, and eventually got annoyed enough to publish. Fifteen files in my API, 288 stars on a three-week-old wrapper, and a two-and-a-half-year-old issue asking for nested filters are all the same report, filed by different people in different places.
Drizzle read that report already. It shipped db.query, then cut the filters to protect one SQL query per call, then took two and a half years to put them back, and #1069 closed with the words "implemented in beta". The core does not actually disagree with the wrapper about what people need. It has just been absorbing the answer one release at a time, reads first.
I filed a bug report against Better Drizzle this week. Better Drizzle is a bug report against Drizzle. The difference is that mine comes with a patch, and the one the wrapper is filing can only be closed by the people who own the schema metadata, because that is where the correlation lives.
Related articles
A follow-up to the Vasarely piece. How I generate deterministic avatars from a username, first as ordered-dithered identicons, then as halftone dot fields where the dot size does the drawing.
A design decision, not a demo. Why I encoded a whole VPS as one Go CLI, pfrctl, instead of renting a PaaS or carrying a heavy self-hosted panel, and where that trade-off pays off.
A defender's-eye tour of how modern anti-bot systems actually work in 2026, from passive TLS and HTTP fingerprints to an obfuscated client that probes your browser, server-side ML scoring, and tokens that expire in seconds. Plus an honest split of what a small team can build and what it has to rent.