better-drizzle
Performance

Benchmarks

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

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

OperationDrizzlebetter-drizzleOverhead
Point lookup25.21 µs26.96 µs+6.9%
Filtered list90.41 µs93.72 µs+3.7%
Simple primary-key order38.70 µs39.77 µs+2.8%
NULLS LAST order33.67 µs37.47 µs+11.3%
Relation graph3.94 ms414.30 µs9.5× faster
Relation counts14.33 ms14.39 ms+0.5%
Active count31.48 µs34.32 µs+9.0%
Exists23.31 µs25.03 µs+7.4%
Offset pagination56.29 µs58.03 µs+3.1%
Cursor pagination53.25 µs55.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.

OperationDrizzlebetter-drizzleOverhead
Point lookup15.04 µs15.57 µs+3.5%
Filtered list115.41 µs118.17 µs+2.4%
Active count28.54 µs29.12 µs+2.0%
Offset pagination46.00 µs46.37 µs+0.8%
Cursor pagination48.81 µs49.44 µs+1.3%

Writes

OperationDrizzlebetter-drizzleOverhead
Create + delete roundtrip58.21 µs60.12 µs+3.3%
Update + reload27.96 µs29.09 µs+4.0%

Transactions

OperationDrizzlebetter-drizzleOverhead
Simple transaction57.69 µs61.86 µs+7.2%
Multi-op transaction148.35 µs135.78 µs−8.5%
Read-only transaction55.63 µs60.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.

BatchHeap 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.
  • Relation loading is the standout win. The batched loader issues one query per relation node 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.

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.

On this page