Four structural reasons
Nothing is interpreted
Queries are compiled to C# at build time. There is no parser or planner on the hot path at runtime.
Indexes you declare
A matching index turns a full scan into a direct lookup or a bounded range walk — chosen by the optimizer.
Allocation-lean paths
Struct storage, ref returns and pooled buffers keep query evaluation off the heap; only the result collection is materialized, so per-query GC stays low.
Deltas, not recomputes
Incremental view maintenance updates reactive results by the size of the change, not the size of the table.
LINQ vs SQLite vs ConjureDB
The same queries, the same 50,000-row data, three ways to run them: the hand-written LINQ a developer reaches for first, embedded SQLite, and ConjureDB. Here is how they stack up.
Fair by construction. Those geomeans deliberately set aside 22 cases that resolve to a sub-microsecond index probe (primary- and foreign-key lookups, top-N off a sorted index) — there ConjureDB does almost no work and the ratio is effectively unbounded, which would flatter the average. Counting all 57 cases it is ~497× vs SQLite and ~3,400× vs LINQ; the numbers above are the conservative slice that still does real scanning, joining and aggregation.
It’s index hits, not magic. The advantage is a declared index turning a full O(n) — or O(n·m) — scan into an O(1)/O(log n) probe. Hand-written LINQ over a plain List<T> has no index, so it walks everything; on correlated queries that is quadratic, which is why naive LINQ lands slower than SQLite in 31 of 57 cases — SQLite at least has a planner and indexes.
Allocations tell the same story. ConjureDB’s best variant allocates 0 B on 26 of 57 cases — it streams from indexes into caller-owned buffers. The naive LINQ churns a geomean of 2.3 MB per query (1007.4 MB across the suite), all of it pressure on the GC — which on a frame budget is its own tax.
And this is the floor, not the ceiling. These are one-shot evaluations — ConjureDB runs the query against its indexes on every call, same as SQLite and the LINQ, and with no incremental view maintenance. For hot, repeatedly-read queries it can go further with IVM: instead of re-evaluating, it applies O(changes) deltas as data is written and reads just return the maintained result. That moves cost to write time — proportional to what changed, not the dataset, and off the synchronous read path — a deliberate read/write trade these benchmarks don’t use.
Every query shape, measured
The verdict above isn't one cherry-picked query. It holds across a governed taxonomy of 12 query families — 57 BenchmarkDotNet cases vs SQLite at 50,000 players, spanning filters, lookups, joins, aggregation, windows, semi/anti/exists, set operations, outer joins, distinct and sort/pagination. All 56 production-tier cases run ≥50×, none slower; the weakest is 54.81×. Every row carries all three baselines side by side — open one for the ConjureDB, LINQ and SQL form and the per-engine timings. Browse by family:
Filter scan and streaming projection
2 cases · 120×–8,807× vs SQLiteThree baselines, same 50,000-row dataset. Select a query for its ConjureDB, LINQ and SQL form.
| Query | LINQ | SQLite | ConjureDB | vs SQLite | Alloc |
|---|---|---|---|---|---|
| Filter Level | 2.38 ms | 4.36 ms | 496 ns | 8,807× | 0 B |
| Filter Level (consume) | 2.28 ms | 3.10 ms | 26.0 µs | 120× | 0 B |
Methodology: ConjureDB and SQLite figures are from the project's authoritative all-case BenchmarkDotNet proof (57 cases) vs SQLite, Release, .NET 8, 50,000-row datasets; the best-measured ConjureDB variant is shown per query. The LINQ baseline is measured separately on the identical dataset and query parameters (in-process timing). These are measurements of one configuration — not a guarantee. Your game is different: schema, hardware and generated plan all matter, so benchmark your own workload. Raw benchmark data ships with the source.
And against a plain Dictionary
SQLite and LINQ are query baselines. For raw collection operations the honest comparison is a Dictionary — and ConjureDB is honest both ways.
vs a plain Dictionary — raw reads
50,000 items, fair fight: the Dictionary gets preset capacity and does none of the work — no transactions, no indexes, no change tracking, no replay-safe commands. ConjureDB still wins primary-key lookups by using a dense-id index and runs level on a contiguous scan.
| Operation | What it does | Dictionary | ConjureDB | Result |
|---|---|---|---|---|
| Search | Look up 50k items by id (dense-id index = a direct array hit) | 753 µs | 56.0 µs | 13× faster |
| Iterate | Scan all 50k — a flat contiguous span | 87.1 µs | 88.4 µs | ≈ parity |
vs a plain Dictionary — CRUD writes
5,000,000 operations, median shown. The known-size Dictionary column uses preset capacity; the default-capacity column shows the common missed-capacity case for insert-like operations where resize/rehash happens inside the measured work.
| Operation | What it does | Dictionary known size | Dictionary default | ConjureDB | Result vs known size |
|---|---|---|---|---|---|
| Add — batched | Insert 5M with bulk Add(T[]) in one transaction | 22.5 ms | 33.5 ms | 28.5 ms | 1.3× slower |
| Add — explicit tx / 10 calls | Insert 5M via BeginTransaction + 10 Add calls + Commit | 22.5 ms | 33.5 ms | 122 ms | 5.4× slower |
| Add — bulk API / 10 rows | Insert 5M via BeginTransaction + Add(T[10]) + Commit | 22.5 ms | 33.5 ms | 60.3 ms | 2.7× slower |
| Add — direct 1 / commit | Insert 5M via the direct single-row commit API | 22.5 ms | 33.5 ms | 23.8 ms | ≈ parity |
| Add — generated mutation | Insert 5M through the generated command/mutation API | 22.5 ms | 33.5 ms | 27.2 ms | 1.2× slower |
| Update — direct 1 / commit | Update 5M existing rows through the direct single-row commit API | 15.0 ms | — | 15.8 ms | ≈ parity |
| Update — generated mutation | Update 5M existing rows through the generated command/mutation API | 15.0 ms | — | 21.8 ms | 1.5× slower |
| Upsert — update existing | Upsert 5M existing rows through the generated command/mutation API | 14.9 ms | — | 22.8 ms | 1.5× slower |
| Upsert — insert miss | Upsert 5M missing rows through the generated command/mutation API | 22.3 ms | 34.0 ms | 38.2 ms | 1.7× slower |
| Remove — batched | Delete 5M with bulk Remove(int[]) in one transaction | 16.0 ms | — | 18.0 ms | 1.1× slower |
| Remove — explicit tx / 10 calls | Delete 5M via BeginTransaction + 10 Remove calls + Commit | 16.0 ms | — | 97.8 ms | 6.1× slower |
| Remove — bulk API / 10 rows | Delete 5M via BeginTransaction + Remove(int[10]) + Commit | 16.0 ms | — | 47.0 ms | 2.9× slower |
| Remove — direct 1 / commit | Delete 5M via the direct single-row commit API | 16.0 ms | — | 19.8 ms | 1.2× slower |
| Remove — generated mutation | Delete 5M through the generated command/mutation API | 16.0 ms | — | 19.7 ms | 1.2× slower |
And a no-JIT lane for config-delivered queries
Everything above is the compiled lane: queries you know at build time, lowered to C# with nothing to interpret at runtime. But some queries ship later — as content or config — and on iOS and Unity/IL2CPP you can't JIT or generate code at runtime to run them. ConjureDB runs those on a portable interpreter: a pre-verified bytecode executed with no JIT, no codegen and no reflection. It's a deliberate second lane, not a fallback — and it stays fast and allocation-lean.
No JIT, no codegen
Config-delivered queries ship as a pre-verified bytecode and run through a register-based interpreter — no runtime IL emission, no reflection — so they work on iOS and Unity/IL2CPP where a JIT cannot.
Allocation-light runtime
Pooled hashtables and arena-backed key/accumulator buffers keep allocation tiny: 0 bytes for a point lookup, and no current 23-scenario vs-SQLite corpus case above 152 bytes per query.
Index-backed access
A primary-key probe is constant-time at about 49 ns in the current vs-SQLite harness, and secondary-index plans use the same ordered accessors as the compiled path. Indexed range top-N is about 18× faster than the portable full-scan variant.
Parity, not approximation
The interpreter produces bit-identical results to the AOT C# lane — same rows, ordering and null semantics — continuously verified by differential and SQL-parity tests.
Every operator, measured against SQLite
The fair fight: SQLite is itself a bytecode interpreter, so this is interpreter vs interpreter. Each of the 23 shapes runs the same query over the same 1000/1000/500-row Players/Orders/Products corpus data, with matching indexes for each scenario and prepared statements — and the no-JIT lane wins all 23, from 4.2–50×, while keeping point lookups at 0 B and the heaviest current 23-scenario corpus case at 152 B.
| Query shape | What it does | SQLite | ConjureDB | vs SQLite | Allocated |
|---|---|---|---|---|---|
| Point lookup | Fetch one row by primary key | 634 ns | 49 ns | 13× faster | 0 B |
| Indexed range top-N | Use score index for range filter + top 10 | 7.59 µs | 433 ns | 18× faster | 40 B |
| Indexed stock top-N | Use stock index for range filter + top 10 | 2.54 µs | 525 ns | 4.8× faster | 40 B |
| Indexed score lookup | Use score index for equality lookup | 22.9 µs | 1.51 µs | 15× faster | 40 B |
| Sort top-N | Sort by price, take 10 | 17.8 µs | 2.57 µs | 6.9× faster | 152 B |
| Distinct | Distinct values of one column | 31.0 µs | 2.98 µs | 10× faster | 40 B |
| Multi-column distinct | Distinct over two columns | 165 µs | 4.28 µs | 38× faster | 96 B |
| Hash aggregate | Count rows grouped by a column | 110 µs | 4.28 µs | 26× faster | 96 B |
| Indexed scan · filter · project | Use level index, then project three columns | 174 µs | 6.26 µs | 28× faster | 40 B |
| Set union | Union two result sets (dedup) | 126 µs | 6.30 µs | 20× faster | 97 B |
| Set intersect | Intersect two result sets (dedup) | 128 µs | 6.34 µs | 20× faster | 97 B |
| Score lookup | Scan/filter rows by score equality | 37.8 µs | 6.60 µs | 5.7× faster | 40 B |
| Scan · filter · project | Filter a table, project three columns | 160 µs | 7.60 µs | 21× faster | 41 B |
| Range top-N | Filter, sort by score, take 10 | 33.4 µs | 7.81 µs | 4.3× faster | 152 B |
| Multi-key aggregate | Count + sum grouped by two columns | 301 µs | 9.30 µs | 32× faster | 97 B |
| Aggregate · double key | Sum grouped by a floating-point column | 471 µs | 9.69 µs | 49× faster | 42 B |
| Window · row_number | row_number() over a sorted partition | 490 µs | 11.5 µs | 43× faster | 129 B |
| Product stock top-N | Full-scan filter, sort by stock, take 10 | 48.3 µs | 11.5 µs | 4.2× faster | 152 B |
| Multi-aggregate | Count/sum/min/max grouped by a column | 152 µs | 12.7 µs | 12× faster | 40 B |
| Window · running sum | Running SUM() over an ordered frame | 772 µs | 15.5 µs | 50× faster | 99 B |
| Aggregate · string key | Count grouped by a string column | 404 µs | 15.9 µs | 25× faster | 42 B |
| Hash join | Inner-join two tables on a key | 381 µs | 17.8 µs | 21× faster | 3 B |
| Complex expression | Arithmetic projection, then filter on the result | 242 µs | 25.6 µs | 9.4× faster | 147 B |
Some of the win is structural: an in-process engine on your own heap skips the managed↔native marshaling SQLite pays per query — which is exactly why you'd embed one on a client. And the compiled C# lane above is still the throughput ceiling; keep your hottest, build-time-known queries there. The portable lane's job is to run the queries the compiled lane structurally can't, on platforms a JIT can't — and to still beat SQLite doing it. The v1 superinstruction pass is measured separately: fused opcode hit scenarios improve+6.5% geomean, with the rest of the corpus flat.
Methodology: Apple M4 Max · .NET 8 · single thread, BenchmarkDotNet, IterationCount=8 after 4 warmups with BenchmarkDotNet's reported outlier filtering. Both engines run the same query over the same 1000/1000/500-row Players/Orders/Products corpus data with a primary-key index plus matching secondary indexes for index-aware scenarios, prepared statements, and the full result materialized; SQLite is in-memory with fast pragmas (synchronous=OFF, temp_store=MEMORY). “vs SQLite” is SQLite's mean ÷ ConjureDB's mean. One configuration — not a guarantee. Benchmark your own workload.
Speed you can audit
The fastest path is the one with nothing dynamic in it. See how the compiler gets there.