---
title: A Wrapper Is a Bug Report
date: '2026-07-15'
description: 'My API has fifteen repository files that are the same file, and thirty `as any` casts in the data layer of an ORM sold on type safety. Better Drizzle exists because everyone writes that layer by hand. I think it belongs in Drizzle itself, and the bug I found while checking makes the case better than I wanted it to.'
author: Pablo Fernandez
tags:
  - Drizzle
  - ORM
  - TypeScript
  - Open Source
  - Postgres
source: 'https://www.pablofr.com/blog/a-wrapper-is-a-bug-report'
---
There is a folder in [this site's API](/blog/more-backend-than-a-blog-needs) 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](https://orm.drizzle.team), 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:

```typescript:src/repositories/postgres/projects.postgres.ts {19}
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](https://orm.drizzle.team/docs/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."

```
   controller  ·  route  ·  service
                      │
                      ▼
   ┌──────────────────────────────────────┐
   │  repositories/postgres      by hand  │
   │  15 files · 3,575 lines · 30 casts   │
   │                                      │
   │  list · count · create · update      │
   │  pagination · updatedAt · filters    │
   └──────────────────┬───────────────────┘
                      │
                      ▼
   ┌──────────────────────────────────────┐
   │  drizzle-orm                         │
   │  select · insert · update · sql      │
   └──────────────────┬───────────────────┘
                      │
                      ▼
                  Postgres
```

*Everything above the ORM in my API is hand-written, and it is the same shape in every file.*

## What Better Drizzle is

[Better Drizzle](https://better-drizzle.com/) 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:

```typescript:client.ts
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:

```typescript:posts.ts
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](https://www.prisma.io/docs/orm/prisma-client/queries/relation-queries), 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](https://github.com/drizzle-team/drizzle-orm/issues/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](https://orm.drizzle.team/docs/rqb) in the v1 line filters parents by relation:

```typescript:query.ts
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.

```
                 db.query ships
                        │
                        ▼
          nested filters cut for speed
                        │
                        │   two and a half years
                        ▼
       #1069 closed · implemented-in-beta
                        │
            ┌───────────┴───────────┐
            ▼                       ▼
   ┌─────────────────┐     ┌─────────────────┐
   │ reads           │     │ writes          │
   │ in core now     │     │ pagination      │
   │                 │     │ hooks · plugins │
   │ where:{ posts } │     │ still by hand   │
   └─────────────────┘     └─────────────────┘
```

*The core has been absorbing the repository layer one release at a time, reads first.*

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:

```text
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`:

```typescript:packages/core/src/shared/query/compiler.ts {2}
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.

```
   the relations config            getTableColumns(posts)
   asks for the column named       hands back a map keyed
   "author_id"                     by JS property name
                                   { id, title, authorId }
              │                              │
              └───────────────┬──────────────┘
                              ▼
                referencedColumns["author_id"]
                              │
                              ▼
                          undefined
                              │
                              ▼
                  the condition is skipped
                              │
                              ▼
   where exists (select 1 from posts where published = $1)
                              │
                              ▼
              no correlation · every row matches
```

*One name-map lookup misses, and the join condition vanishes instead of raising.*

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](https://github.com/almeidazs/better-drizzle/issues/26) and sent [PR #27](https://github.com/almeidazs/better-drizzle/pull/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.

| What I hand-write | In Drizzle core |
| --- | --- |
| Filter parents by a relation | Yes, in the v1 line |
| Writes through the same object API | No, `db.query` is read-only |
| `{ data, pagination }` with count and `hasNext` | No, [a guide showing you the maths](https://orm.drizzle.team/docs/guides/limit-offset-pagination) |
| Lifecycle hooks for audit or tracing | No, `/docs/hooks` is a 404 |
| Plugins for timestamps or soft delete | No, `/docs/plugins` is a 404 |
| Upsert | Yes, `.onConflictDoUpdate()` |

There is a second gap underneath that one. [`drizzle.config.ts`](https://orm.drizzle.team/docs/drizzle-config-file) 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.
