# Benchmarks

> Wrapper overhead vs raw Drizzle across latency and memory, measured with fair API-parity comparisons.

URL: https://better-drizzle.com/docs/performance/benchmarks

better-drizzle is not trying to beat raw Drizzle at being raw Drizzle. It is trying to stay close while giving you a higher-level repository API. The benchmark suite quantifies exactly that overhead - including where the overhead is real.

> **How these numbers are produced:** Generated by `bun run bench:report` on SQLite in-memory, to isolate wrapper
> overhead from I/O. Both sides of every pair are sampled in the same window,
> alternating which one leads, and the median across samples is reported.
> Measurement uses mitata's engine, the same one behind `bun run bench`.

> **Read the percentages, not the microseconds:** Absolute timings drift with machine load - the same lookup reads 53 µs on an
> idle machine and 93 µs under load. The **ratio between the two sides is
> stable across runs**; the absolute values are specific to the machine that
> produced them. Reproduce locally rather than treating the µs column as a
> spec.

## Latency

Per-operation latency at the **API-parity** level - raw Drizzle does the same work and returns the same shape.

### Reads

| Operation                | Drizzle  | better-drizzle | Overhead        |
| ------------------------ | -------- | -------------- | --------------- |
| Point lookup             | 25.21 µs | 26.96 µs       | +6.9%           |
| Filtered list            | 90.41 µs | 93.72 µs       | +3.7%           |
| Simple primary-key order | 38.70 µs | 39.77 µs       | +2.8%           |
| NULLS LAST order         | 33.67 µs | 37.47 µs       | +11.3%          |
| Relation graph           | 3.94 ms  | 414.30 µs      | **9.5× faster** |
| Relation counts          | 14.33 ms | 14.39 ms       | +0.5%           |
| Active count             | 31.48 µs | 34.32 µs       | +9.0%           |
| Exists                   | 23.31 µs | 25.03 µs       | +7.4%           |
| Offset pagination        | 56.29 µs | 58.03 µs       | +3.1%           |
| Cursor pagination        | 53.25 µs | 55.94 µs       | +5.0%           |

### Prepared reads

Each pair prepares once and executes with new values: Drizzle with
`sql.placeholder()` + `.prepare()`, better-drizzle with `param()` +
`.prepare()`, both returning the same shape (including pagination metadata).
These rows come from a later run of the same report than the tables around
them, so compare the percentages rather than the microseconds.

| Operation         | Drizzle   | better-drizzle | Overhead |
| ----------------- | --------- | -------------- | -------- |
| Point lookup      | 15.04 µs  | 15.57 µs       | +3.5%    |
| Filtered list     | 115.41 µs | 118.17 µs      | +2.4%    |
| Active count      | 28.54 µs  | 29.12 µs       | +2.0%    |
| Offset pagination | 46.00 µs  | 46.37 µs       | +0.8%    |
| Cursor pagination | 48.81 µs  | 49.44 µs       | +1.3%    |

### Writes

| Operation                 | Drizzle  | better-drizzle | Overhead |
| ------------------------- | -------- | -------------- | -------- |
| Create + delete roundtrip | 58.21 µs | 60.12 µs       | +3.3%    |
| Update + reload           | 27.96 µs | 29.09 µs       | +4.0%    |

### Transactions

| Operation             | Drizzle   | better-drizzle | Overhead |
| --------------------- | --------- | -------------- | -------- |
| Simple transaction    | 57.69 µs  | 61.86 µs       | +7.2%    |
| Multi-op transaction  | 148.35 µs | 135.78 µs      | −8.5%    |
| Read-only transaction | 55.63 µs  | 60.62 µs       | +9.0%    |

## Memory

Heap delta across batches of operations, from `bun run bench:memory`.

Heap deltas are noisier than latency ratios, so these are the ranges observed across runs rather than a single snapshot.

| Batch       | Heap overhead  |
| ----------- | -------------- |
| Single read | −45% to −50%   |
| Write       | −8% to −25%    |
| Mixed read  | +165% to +210% |
| Transaction | +91% to +107%  |

## How to read this

* **Reads land within \~12% of raw Drizzle**, and most are within 9%. Point lookups, filtered lists, and existence checks add little overhead; explicit `NULLS LAST` is the highest at +11.3%.
* **Prepared reads land within \~4% of Drizzle's own prepared statements.** In the same run, preparing a read cut its time by 1.5× to 3.1× compared with the regular read (point lookup 48.45 µs → 15.57 µs). See [prepared statements](/docs/querying/prepared-statements).
* **Relation loading is the standout win.** The batched loader normally issues one query per relation node, splitting large parent-key sets into bounded batches, instead of the per-row work the equivalent raw code ends up doing, which is why the relation graph is \~9.5× faster rather than marginally slower. This gap grows with the number of parent rows.
* **Cursor pagination is at parity.** A populated page ordered by one primary key computes exact navigation metadata with one indexed query; empty pages and more complex shapes keep a probe when they need one. Note that both sides resolve `hasPrevious` - the raw comparison does it with a correlated `exists` subquery rather than assuming it. An earlier revision of this suite let the raw side hardcode that value, which quietly compared one query against two. The manual data-only reference is still faster because it skips the navigation check entirely.
* **Writes are within \~5%** at parity.
* **Transactions are within \~9%.** Nested savepoints are a better-drizzle-only capability with no raw Drizzle equivalent, so they are not part of the parity table.
* **Memory is mixed.** Single reads use materially less heap; mixed-read batches and transactions use more, because relation state, hooks, and savepoint tracking have to be held somewhere.

## Comparison groups

The suite reports three views on purpose:

1. **API parity** - the fair comparison. Same work, same shape (relations loaded, pagination metadata computed).
2. **Transaction parity** - the same operations wrapped in a transaction.
3. **Manual Drizzle reference** - intentionally lower-level raw queries (flat joins, no relation resolution, no pagination metadata). These are *faster* but do *less*.

> **Don't compare across groups:** The manual reference does less work, so it is not a fair overhead claim. Use
> the API-parity group as the headline number - see [parity](/docs/performance/parity).

**PostgreSQL only:**

## PostgreSQL JSONB

Typed JSONB path filters have their own suite (`bun run bench:jsonb`) because they need a real PostgreSQL instance. It seeds 100,000 rows and asserts two things before timing:

* **Row parity** - both sides return the same rows.
* **Plan parity** - both sides reach them the same way, verified with `EXPLAIN` on each side.

The typed form compiles to the same index scan as the hand-written SQL; PostgreSQL folds the parameterised path into the indexed expression. better-drizzle adds one `jsonb_typeof` filter that the hand-written version omits, which is what keeps a row with a string where its neighbours have a number from failing the cast.

That suite is I/O bound, so the two sides land within run-to-run noise of each other. It exists to prove the query is the same, not to produce an overhead figure.
