Table of Contents

Benchmarks: Nextorm vs Dapper, linq2db and EF Core

Benchmark conditions: provider SQLite (Microsoft.Data.Sqlite) for every participant; async queries; each cell is the fastest method for that library in the linked BenchmarkDotNet report. Machine: AMD EPYC 7763 (4 logical / 2 physical cores), .NET 11.0.0, BenchmarkDotNet 0.15.8 (ShortRun + InProcessEmitToolchain). As of 2026-11-11 for the end-to-end, allocations and Category B tables (the Category A table and the tier-1 section keep the date of their own run). In each row the leading cell is bold — the fastest in the time tables and the smallest allocation in the allocations table.

Feature-level view: a per-feature, tiered comparison — no Prepare() arms, one table per tier, competitors as columns — is in Feature benchmarks.

End-to-end scenarios

Method Nextorm Dapper linq2db EF Core
Data fetch 10.75 μs 15.80 μs 16.50 μs 38.46 μs
Wide data fetch 9.135 ms 9.883 ms 10.594 ms 10.998 ms
Where 915.0 μs 1,378.8 μs 1,432.3 μs 3,540.1 μs
Simulate work 8.610 ms 27.766 ms 418.616 ms 109.613 ms
Any 923.5 μs 1,421.9 μs 1,523.8 μs 3,742.6 μs
FirstOrDefault 93.64 μs 148.96 μs 363.31 μs 368.44 μs
SingleOrDefault 92.38 μs 142.69 μs 372.78 μs 369.31 μs

Allocations

Allocations for the same fastest method per library (BenchmarkDotNet Allocated):

Method Nextorm Dapper linq2db EF Core
Data fetch 968 B 1,848 B 2,080 B 9,776 B
Wide data fetch 2.14 MB 2.59 MB 2.14 MB 4.28 MB
Where 102.58 KB 179.23 KB 252.74 KB 745.41 KB
Simulate work 6.21 MB 8.86 MB 107.45 MB 13.57 MB
Any 84.38 KB 138.29 KB 232.82 KB 763.30 KB
FirstOrDefault 8.56 KB 16.12 KB 159.77 KB 76.03 KB
SingleOrDefault 8.28 KB 16.04 KB 159.77 KB 76.03 KB

Feature-level comparison (fair)

The end-to-end table mixes reuse strategies. To make the comparison symmetric, features are split into two categories:

  • Category A — prepared / compiled / raw: Nextorm through Prepare() against the closest compiled form of the same query (EF.CompileAsyncQuery, LinqToDB.CompiledQuery.Compile) and raw Dapper (whose IL is cached by SQL text). This is "compiled vs compiled".
  • Category B — warm cached vs regular: Nextorm through the implicit plan cache (a fresh fluent command with constants on every call, so the cached plan is the only reusable part) against regular (non-compiled) EF Core and linq2db and raw Dapper.

Each feature method runs 10 queries; every cell is the per-query mean and allocation (the BenchmarkDotNet Mean and Allocated divided by 10). Source reports: Category A · Category B.

Category A — prepared vs compiled vs raw

As of 2026-11-11.

Feature Nextorm (prepared) Dapper linq2db (compiled) EF Core (compiled)
CASE WHEN 9.45 μs / 664 B 14.64 μs / 1.34 KB 15.43 μs / 1.59 KB 36.91 μs / 7.46 KB
CTE 9.89 μs / 712 B 22.25 μs / 1.56 KB 23.75 μs / 1.77 KB —
SELECT DISTINCT 11.44 μs / 665 B 18.27 μs / 1.44 KB 18.36 μs / 1.59 KB 42.53 μs / 7.23 KB
EXCEPT 11.41 μs / 680 B 18.13 μs / 1.43 KB 21.57 μs / 1.67 KB 46.40 μs / 8.38 KB
IN-list 12.79 μs / 1.55 KB 18.55 μs / 1.26 KB 47.92 μs / 13.63 KB 66.24 μs / 14.34 KB
INTERSECT 10.59 μs / 665 B 16.64 μs / 1.28 KB 20.33 μs / 1.62 KB 44.07 μs / 7.50 KB
4-table join 13.27 μs / 1,000 B 27.17 μs / 2.02 KB 24.65 μs / 2.06 KB 50.99 μs / 8.05 KB
LEFT JOIN 12.65 μs / 1.12 KB 21.22 μs / 2.21 KB 21.63 μs / 2.31 KB 45.27 μs / 9.48 KB
Recursive CTE 12.95 μs / 672 B 26.02 μs / 1.52 KB 28.50 μs / 1.77 KB —
String functions 9.33 μs / 656 B 14.22 μs / 1.27 KB 15.25 μs / 1.63 KB 36.89 μs / 7.05 KB
UDF 9.43 μs / 688 B 14.28 μs / 1.27 KB 15.31 μs / 1.63 KB 36.24 μs / 7.05 KB
Window functions 14.99 μs / 768 B 31.84 μs / 1.53 KB 34.65 μs / 1.73 KB —

Nextorm prepared is the fastest on every feature and has the smallest allocation on 11 of 12 — IN-list is the exception, where Dapper's cached raw SQL allocates less. The exact ratios are in the table above; they move with the runner, so treat deltas below ~20 % as noise.

Category B — warm cached vs regular

Feature Nextorm (cached) Dapper linq2db EF Core
CASE WHEN 12.69 μs / 2.04 KB 14.89 μs / 1.34 KB 24.93 μs / 3.77 KB 60.62 μs / 11.46 KB
CTE 31.54 μs / 9.27 KB 22.60 μs / 1.56 KB 60.15 μs / 9.02 KB —
SELECT DISTINCT 15.56 μs / 2.98 KB 17.90 μs / 1.44 KB 30.64 μs / 3.88 KB 68.58 μs / 11.70 KB
EXCEPT 17.38 μs / 4.12 KB 18.59 μs / 1.43 KB 40.69 μs / 5.01 KB 80.83 μs / 14.73 KB
IN-list 43.85 μs / 8.59 KB 19.46 μs / 1.26 KB 74.64 μs / 17.28 KB 116.25 μs / 22.05 KB
INTERSECT 16.31 μs / 4.10 KB 17.14 μs / 1.28 KB 39.33 μs / 4.96 KB 78.83 μs / 13.81 KB
4-table join 35.21 μs / 9.92 KB 28.06 μs / 2.02 KB 70.15 μs / 14.15 KB 127.10 μs / 23.97 KB
LEFT JOIN 23.83 μs / 5.49 KB 21.60 μs / 2.21 KB 76.56 μs / 9.02 KB 104.27 μs / 19.66 KB
Recursive CTE 33.93 μs / 9.84 KB 26.32 μs / 1.52 KB 67.39 μs / 8.62 KB —
String functions 13.45 μs / 2.78 KB 14.73 μs / 1.27 KB 31.63 μs / 4.74 KB 70.22 μs / 12.74 KB
UDF 13.86 μs / 2.87 KB 14.67 μs / 1.27 KB 50.31 μs / 6.63 KB 70.22 μs / 12.74 KB
Window functions 29.80 μs / 4.16 KB 32.83 μs / 1.54 KB 67.61 μs / 5.79 KB —

In the warm path Nextorm beats Dapper on CASE WHEN and SELECT DISTINCT and is level on INTERSECT; it trails Dapper by about 1.06–1.20× on string functions, EXCEPT, UDF and window functions, by 1.38× on LEFT JOIN, and by 1.56–1.82× on 4-table join, CTE, recursive CTE and IN-list — while staying well ahead of regular linq2db and EF Core on time. Warm-path allocations are higher than the prepared path (the plan key is rebuilt per call); they stay far below regular EF Core and below linq2db on most features, but exceed linq2db on CTE, recursive CTE, EXCEPT and INTERSECT.

IN-list, Category B. A captured collection disables the plan cache by design (each call re-renders the SQL), so the captured variants pay a full build; the inline variants hit the cache. The four Nextorm variants span 37.5–41.4 µs — this is the feature where Dapper's cached raw SQL is furthest ahead.

Tier-1 query-shape scenarios

Added in #188 to cover query shapes the two tables above do not exercise: projection into a fresh DTO, SQL-side aggregates, ordered paging, streaming (buffered/unbuffered DTO vs. zero-materialization raw readers) and cross-library JSON/CSV output. Data size: large_table (10,000 rows) for projection/aggregates/paging/streaming; the JSON class seeds its own json_bench table and the CSV class slices large_table (10,000 rows). The fair-comparison fixture invariants stay simple_entity=10 and complex_entity=3 — the "10,000 rows" above is large_table, not those fixtures. As of 2026-11-07.

Reuse categories are the same as the feature comparison: Category A — Nextorm Prepare() against the closest compiled form (EF.CompileAsyncQuery, LinqToDB.CompiledQuery.Compile) plus raw Dapper; Category B — Nextorm's implicit plan cache against regular (non-compiled) EF Core/linq2db and raw Dapper. Dapper has no compiled query/reader form, so its arms carry both categories.

Streaming has three groups: buffered DTO materialization, unbuffered DTO enumeration, and a zero-materialization raw-reader subgroup. The zero subgroup is synchronous only (async reader arms are deferred): Nextorm ToDataReader, Dapper ExecuteReader and linq2db ExecuteReader (its DataReaderAsync.Reader) all read the same id, someString projection through one shared non-retaining sink. EF Core is explicitly not in the zero subgroup — it has no LINQ raw-reader counterpart. "Zero-materialization" means no entity/DTO construction, not zero allocations: the reader path still allocates its per-call command/reader.

For JSON/CSV the competitor arms are a closest equivalent: neither Dapper, linq2db nor EF Core has a non-materialising output terminal, so their arm is materialize → manual serializer. This is not a zero-materialization claim.

Reading these numbers. ShortRun + InProcessEmitToolchain is exploratory: treat deltas below ~20% as noise and read the full report (error, standard deviation, allocations) before drawing conclusions. The tables report BenchmarkDotNet Mean/Allocated as-is; they are not a universal-win claim.

Projection — Select(x => new Dto { Id, Str })

Reproduce: dotnet run --project benchmarks/nextorm.benchmark -c Release -- --filter *SqliteBenchmarkProjection*. Report.

Arm Category Mean Allocated
A_Nextorm_Prepared_ToListAsync A 4.284 ms 1.3 MB
A_EFCore_Compiled_ToListAsync A 5.531 ms 3.36 MB
A_Linq2Db_Compiled_ToList A 6.327 ms 1.47 MB
A_Dapper_ToListAsync A+B 7.435 ms 1.7 MB
B_Nextorm_Cached_ToListAsync B 4.370 ms 1.3 MB
B_Linq2Db_ToListAsync B 6.597 ms 1.48 MB
B_EFCore_ToListAsync B 9.956 ms 3.62 MB

Aggregates — SQL-side Count / Sum / GroupBy → Count

Reproduce: dotnet run --project benchmarks/nextorm.benchmark -c Release -- --filter *SqliteBenchmarkAggregates*. Report.

Operation Arm Category Mean Allocated
Count A_Nextorm_Prepared_Count A 14.65 μs 408 B
Count A_Linq2Db_Compiled_Count A 19.26 μs 1,280 B
Count A_EFCore_Compiled_Count A 40.92 μs 7,744 B
Count Dapper_Count A+B 18.23 μs 944 B
Count B_Nextorm_Cached_Count B 15.39 μs 408 B
Count B_Linq2Db_Count B 29.52 μs 3,008 B
Count B_EFCore_Count B 64.48 μs 10,080 B
Sum A_Nextorm_Prepared_Sum A 298.17 μs 410 B
Sum A_Linq2Db_Compiled_Sum A 316.45 μs 1,290 B
Sum A_EFCore_Compiled_Sum A 334.54 μs 7,770 B
Sum Dapper_Sum A+B 317.79 μs 954 B
Sum B_Nextorm_Cached_Sum B 314.20 μs 970 B
Sum B_Linq2Db_Sum B 367.16 μs 3,490 B
Sum B_EFCore_Sum B 371.48 μs 10,978 B
GroupBy→Count A_Nextorm_Prepared_GroupByCount A 1,468.98 μs 4,666 B
GroupBy→Count A_EFCore_Compiled_GroupByCount A 1,501.91 μs 33,090 B
GroupBy→Count A_Linq2Db_Compiled_GroupByCount A 1,511.01 μs 7,026 B
GroupBy→Count Dapper_GroupByCount A+B 1,477.65 μs 12,330 B
GroupBy→Count B_Nextorm_Cached_GroupByCount B 1,554.85 μs 10,146 B
GroupBy→Count B_Linq2Db_GroupByCount B 1,675.00 μs 11,554 B
GroupBy→Count B_EFCore_GroupByCount B 1,886.06 μs 42,754 B

Paging — OrderBy(Id).Offset(5000).Limit(100)

Reproduce: dotnet run --project benchmarks/nextorm.benchmark -c Release -- --filter *SqliteBenchmarkPaging*. Report.

Arm Category Mean Allocated
A_Nextorm_Prepared_PageAsync A 88.54 μs 13.92 KB
A_Linq2Db_Compiled_Page A 112.67 μs 17.61 KB
A_EFCore_Compiled_PageAsync A 133.15 μs 44.81 KB
A_Dapper_PageAsync A+B 106.51 μs 18.15 KB
B_Nextorm_Cached_PageAsync B 108.62 μs 22.33 KB
B_Linq2Db_PageAsync B 139.58 μs 22.65 KB
B_EFCore_PageAsync B 187.81 μs 56.9 KB

Streaming — buffered / unbuffered DTO and zero-materialization readers

Reproduce: dotnet run --project benchmarks/nextorm.benchmark -c Release -- --filter *SqliteBenchmarkStreaming*. Report.

Buffered DTO:

Arm Category Mean Allocated
A_Nextorm_Prepared_ToList_Dto A 3,956.46 μs 1,328.77 KB
Linq2Db_Compiled_ToList_Dto A 5,960.94 μs 1,508.22 KB
A_EFCore_Compiled_ToList_Dto A 10,629.30 μs 3,701.53 KB
Dapper_ToList_Dto A+B 8,223.40 μs 1,742.27 KB
B_Nextorm_Cached_ToList_Dto B 3,954.38 μs 1,332.18 KB
B_Linq2Db_ToList_Dto B 6,128.13 μs 1,511.21 KB
B_EFCore_ToList_Dto B 9,005.49 μs 3,705.72 KB

Unbuffered DTO enumeration:

Arm Category Mean Allocated
A_Nextorm_Prepared_AsyncStream_Dto A 4,585.96 μs 1,250.84 KB
A_EFCore_Compiled_AsyncStream_Dto A 5,292.02 μs 3,444.77 KB
Dapper_AsyncStream_Dto A+B 4,785.98 μs 1,485.82 KB
B_Nextorm_Cached_AsyncStream_Dto B 4,255.35 μs 1,254.42 KB
B_Linq2Db_AsyncStream_Dto B 4,982.80 μs 1,254.55 KB
B_EFCore_AsyncStream_Dto B 5,295.63 μs 3,448.89 KB

Zero-materialization raw readers (synchronous; shared non-retaining sink; EF Core excluded):

Arm Category Mean Allocated
A_Nextorm_Prepared_ToDataReader A 4,453.12 μs 948.45 KB
Dapper_ToDataReader B 3,954.36 μs 943.1 KB
Linq2Db_ToDataReader B 4,006.42 μs 943.33 KB

JSON / CSV cross-library

Reproduce JSON: dotnet run --project benchmarks/nextorm.benchmark -c Release -- --filter *SqliteBenchmarkWriteJson*. Report. Reproduce CSV: dotnet run --project benchmarks/nextorm.benchmark -c Release -- --filter *SqliteBenchmarkCsv*. Report.

The JSON report covers RowCount 1,000 / 10,000 / 100,000; the 10,000-row block is shown (Ratio is relative to ToList_Dto in the same block). Competitor arms are materialize → manual serializer.

Method (10,000 rows) Mean Ratio Allocated
WriteJsonAsync_Array_Scalar 5,452.9 μs 0.88 9.98 KB
ToList_Dto 6,196.4 μs 1.00 785.02 KB
WriteJson_Array_Scalar 6,294.1 μs 1.02 9.83 KB
Nextorm_ToList_Dto_Json 7,261.4 μs 1.17 785.33 KB
WriteJson_Array_Dto 8,302.0 μs 1.34 403.24 KB
Linq2Db_ToList_Json 8,793.1 μs 1.42 963.73 KB
Dapper_ToList_Json 9,459.1 μs 1.53 1,273.77 KB
WriteJsonAsync_Array_ScalarPayload 9,720.8 μs 1.57 11,493.34 KB
WriteJson_Array_ScalarPayload 10,431.7 μs 1.68 11,493.19 KB
EFCore_ToList_Json 10,952.7 μs 1.77 3,158.63 KB
WriteJson_Array_WideDto 12,280.9 μs 1.98 11,496.04 KB

CSV (large_table slice, 10,000 rows; Ratio relative to Nextorm_ToList):

Method Mean Ratio Allocated
Nextorm_StringProjection_ToList 2.799 ms 0.34 1,019.05 KB
Nextorm_StringProjection_WriteCsv 3.423 ms 0.41 948.99 KB
Nextorm_ToList 8.278 ms 1.00 2,267.52 KB
Nextorm_ToList_ManualCsv 9.806 ms 1.18 4,141.82 KB
Nextorm_WriteCsv 10.241 ms 1.24 1,733.64 KB
Dapper_ToList_ManualCsv 14.115 ms 1.71 4,866.08 KB
Linq2Db_ToList_ManualCsv 14.635 ms 1.77 4,321.92 KB
EFCore_ToList_ManualCsv 16.190 ms 1.96 6,516.91 KB

NativeAOT startup (reference, not a query benchmark)

AOT moves the cost from every start to publish time. On the oc-report case (13 GB SQLite, 3-day window) the same report runs in 0.42 s under JIT and 0.12 s as a NativeAOT binary; EF Core 10 goes 1.03 s → 0.23 s and Dapper 0.26 s → 0.12 s. The SQL and row counts are identical across the three, and the measurement is dominated by one session_message scan — read it as startup cost, not query throughput. Only nextorm publishes with PublishAot=true alone; the nextorm arm ships as examples/nextorm.examples.oc-report, see the NativeAOT guide.

Reading the numbers

  • Every participant uses the same ADO.NET provider (Microsoft.Data.Sqlite), so the comparison is apples-to-apples; the SQLite database lives on tmpfs so storage I/O does not mask the ORM.
  • Nextorm reports both of its reuse paths — the implicit plan cache and explicit Prepare() — and the end-to-end cell is the fastest of them. See Query reuse: cache vs Prepare.
  • ShortRun + InProcessEmitToolchain is a quick mode: it is stable enough for a signal, but treat small differences as noise and read the full report (mean, error, standard deviation and allocations) before drawing conclusions.
  • This is one machine, one provider (SQLite) and a fixed set of scenarios. It is not a claim about every workload — reproduce it with your own data and hardware.

Reproducing

# End-to-end scenarios ("*SqliteBenchmarkIteration*" does not match SqliteBenchmarkLargeIteration:
# the type name differs after the "SqliteBenchmark" prefix)
for filter in "*SqliteBenchmarkIteration*" "*SqliteBenchmarkLargeIteration*" "*SqliteBenchmarkWhere*" \
              "*SqliteBenchmarkSimulateWork*" "*SqliteBenchmarkAny*" "*SqliteBenchmarkFirst*" \
              "*SqliteBenchmarkSingle*"; do
  dotnet run --project benchmarks/nextorm.benchmark -c Release -- --filter "$filter"
done

# Feature-level fair comparison (Category A / B)
dotnet run --project benchmarks/nextorm.benchmark -c Release -- --filter "*SqliteBenchmarkFeaturesFair.*"
dotnet run --project benchmarks/nextorm.benchmark -c Release -- --filter "*SqliteBenchmarkFeaturesFairCached*"

Each linked report is the BenchmarkDotNet output committed under benchmarks/BenchmarkDotNet.Artifacts/results/. Set NEXTORM_BENCH_FULL=1 to run the full out-of-process job instead of ShortRun.

The end-to-end, allocations, Category A and Category B tables on this page are generated from those reports — do not edit them by hand. After a run:

python3 tools/benchmarks/update_benchmark_tables.py          # rewrite readme.md and both article mirrors
python3 tools/benchmarks/update_benchmark_tables.py --check  # CI mode: fail when a table is stale

The same refresh runs from .github/workflows/benchmarks.yml — automatically after every merge into main that touches the engine (src/**) or the benchmark project, and on demand from the Actions tab. It opens a pull request with the new reports and tables.

See also