Dapper · Dapper.SimpleCRUD · EF Core
The same table and the same operations on every database: first the libraries on their own, then through an instant OData/REST API built on each. Lower is better. Generated 2026-09-30 21:50 UTC.
- Dapper.SimpleCRUD against hand-written Dapper (geometric mean across databases): get by id 1.24×, filtered page (20 rows) 1.10×, insert one 1.04×, update one 1.10×, insert + delete 1.05×, insert 100 (one transaction) 1.38×.
- EF Core against hand-written Dapper (geometric mean across databases): get by id 1.60×, filtered page (20 rows) 1.41×, insert one 1.21×, update one 1.20×, insert + delete 1.15×, insert 100 (one transaction) 0.30×.
- The EzOdata facade (SimpleCRUD in an isolated copy per dialect) against SimpleCRUD's static API on SQLite: get by id +1%, filtered page (20 rows) +1%, insert one +2%, update one -6%, insert + delete -2%, insert 100 (one transaction) +0%; 6 of 6 are inside the confidence intervals.
- EF Core change tracking on reads, relative to Dapper: get by id 1.60× tracked vs 1.61× AsNoTracking, filtered page (20 rows) 1.41× tracked vs 1.35× AsNoTracking.
- Allocations per operation, SimpleCRUD / EF Core relative to Dapper: get by id 3.4× / 2.8×, filtered page (20 rows) 2.2× / 3.4×, insert one 1.7× / 3.1×, update one 2.7× / 3.4×, insert + delete 1.8× / 3.6×, insert 100 (one transaction) 3.2× / 3.6×.
- Reads through the HTTP API take the same path on every engine (ez-odata's compiled SQL); median difference from stock ez-odata, SimpleCRUD / EF Core engine: get-by-key +0.01 ms / +0.01 ms, list +0.01 ms / 0.00 ms, expand 0.00 ms / 0.00 ms.
- Writes through the HTTP API (typed entity, hooks, one transaction), median difference from stock ez-odata, SimpleCRUD / EF Core engine: post +0.30 ms / +1.04 ms, patch +0.63 ms / +1.44 ms, put +0.58 ms / +0.36 ms.
- Tests: 71 passed, 0 failed, 0 skipped.
The libraries on their own
No HTTP and no ez-odata: each library does the operation the way its documentation recommends, opening a pooled connection per operation as a web request would. Bars are the mean, whiskers the 99.9% confidence interval; on the right, the mean (with the ratio to Dapper) and the bytes allocated. Hover a bar for the median and sample count.
Get by id
Filtered page (20 rows)
Insert one
Update one
Insert + delete
Insert 100 (one transaction)
Through the HTTP API
An in-memory TestServer per engine: stock ez-odata (untyped), the SimpleCRUD engine and the EF Core engine (typed entities, hooks, a transaction per write). For each database and scenario the engines take turns request by request after a shared warm-up. Median / p95 in ms; bytes allocated per request across the whole process.
SQLite
| scenario | stock | simplecrud | efcore |
|---|---|---|---|
| post | 0.53 / 0.74 56.5 KB | 0.56 / 0.76 64.8 KB | 0.76 / 1.06 128.8 KB |
| get-by-key | 0.14 / 0.21 53.1 KB | 0.14 / 0.18 54.9 KB | 0.15 / 0.19 55.2 KB |
| list | 0.25 / 0.33 98.2 KB | 0.26 / 0.34 99.0 KB | 0.25 / 0.33 99.1 KB |
| expand | 0.22 / 0.42 80.7 KB | 0.23 / 0.44 83.0 KB | 0.22 / 0.36 82.6 KB |
| patch | 0.49 / 0.94 55.8 KB | 0.55 / 0.97 79.9 KB | 0.80 / 1.38 141.0 KB |
| put | 0.19 / 0.26 58.3 KB | 0.22 / 0.30 80.1 KB | 0.35 / 0.49 132.7 KB |
| delete-post | 0.87 / 1.40 89.3 KB | 0.93 / 1.42 117.1 KB | 1.31 / 2.36 243.4 KB |
| concurrent-16 | 1659.49 / 2114.41 921.7 KB | 1516.28 / 2113.67 1.02 MB | 1667.12 / 2115.38 2.00 MB |
| hook-audit | — | 0.30 / 0.45 68.0 KB | 0.38 / 0.56 137.5 KB |
| direct-insert | — | 0.31 / 0.39 7.8 KB | 0.35 / 0.47 70.5 KB |
PostgreSQL
| scenario | stock | simplecrud | efcore |
|---|---|---|---|
| post | 1.00 / 1.26 60.8 KB | 1.41 / 1.86 69.5 KB | 2.22 / 2.81 131.1 KB |
| get-by-key | 0.56 / 0.70 59.3 KB | 0.57 / 0.75 61.5 KB | 0.57 / 0.72 61.0 KB |
| list | 0.72 / 0.83 103.8 KB | 0.73 / 0.84 105.1 KB | 0.72 / 0.85 105.6 KB |
| expand | 1.09 / 1.74 91.3 KB | 1.09 / 1.82 93.3 KB | 1.08 / 1.66 92.7 KB |
| patch | 0.89 / 1.36 59.9 KB | 1.65 / 2.63 84.2 KB | 2.58 / 3.86 142.4 KB |
| put | 0.79 / 0.97 62.1 KB | 1.49 / 1.88 84.9 KB | 1.31 / 1.63 129.9 KB |
| delete-post | 1.55 / 1.89 98.5 KB | 2.28 / 2.94 125.5 KB | 3.72 / 4.42 242.3 KB |
| concurrent-16 | 1.91 / 3.65 1.11 MB | 2.60 / 4.64 1.19 MB | 4.62 / 7.60 2.11 MB |
| hook-audit | — | 1.95 / 3.25 71.1 KB | 3.62 / 6.23 142.1 KB |
| direct-insert | — | 0.39 / 0.53 6.2 KB | 0.44 / 0.62 62.4 KB |
MySQL
| scenario | stock | simplecrud | efcore |
|---|---|---|---|
| post | 1.98 / 2.43 69.6 KB | 2.04 / 2.48 73.2 KB | — |
| get-by-key | 0.82 / 0.97 61.0 KB | 0.83 / 0.97 62.5 KB | — |
| list | 1.03 / 1.43 105.9 KB | 1.04 / 1.46 106.3 KB | — |
| expand | 1.58 / 1.87 93.2 KB | 1.59 / 1.91 96.2 KB | — |
| patch | 1.87 / 3.19 67.8 KB | 2.24 / 3.88 90.1 KB | — |
| put | 1.55 / 2.24 70.0 KB | 1.90 / 2.96 90.5 KB | — |
| delete-post | 3.52 / 7.14 106.9 KB | 3.97 / 9.21 133.5 KB | — |
| concurrent-16 | 4.62 / 6.80 1.13 MB | 4.65 / 7.15 1.21 MB | — |
| hook-audit | — | 2.63 / 3.41 76.3 KB | — |
| direct-insert | — | 1.11 / 1.43 9.1 KB | — |
SQL Server
| scenario | stock | simplecrud | efcore |
|---|---|---|---|
| post | 2.61 / 4.99 64.4 KB | 3.30 / 6.22 75.5 KB | 4.27 / 7.69 173.2 KB |
| get-by-key | 0.82 / 1.34 59.8 KB | 0.83 / 1.45 61.5 KB | 0.84 / 1.40 60.9 KB |
| list | 0.97 / 1.27 104.5 KB | 0.99 / 1.17 104.2 KB | 0.98 / 1.28 104.9 KB |
| expand | 1.52 / 1.77 92.1 KB | 1.51 / 1.79 94.1 KB | 1.52 / 1.84 93.9 KB |
| patch | 2.47 / 2.89 63.4 KB | 3.81 / 4.41 94.8 KB | 4.77 / 5.50 187.6 KB |
| put | 2.50 / 3.59 65.4 KB | 3.75 / 5.53 95.4 KB | 2.90 / 3.90 170.5 KB |
| delete-post | 5.10 / 6.49 101.8 KB | 6.60 / 8.94 137.9 KB | 8.50 / 10.81 326.2 KB |
| concurrent-16 | 8.97 / 16.03 1.07 MB | 11.62 / 20.28 1.23 MB | 14.77 / 26.40 2.72 MB |
| hook-audit | — | 4.23 / 5.39 82.7 KB | 5.85 / 7.67 189.9 KB |
| direct-insert | — | 1.22 / 1.45 10.9 KB | 1.32 / 1.58 101.6 KB |
Tests
| group | passed | failed | skipped | time |
|---|---|---|---|---|
| EF Core engine suites | 18 | 0 | 0 | 2.2 s |
| facade, engines and API | 35 | 0 | 0 | 3.7 s |
| SimpleCRUD engine suites | 18 | 0 | 0 | 1.9 s |
Every test
| EzOdata.Entities.AspNetCore.Tests.EfCore_CompositeKeyTests.Composite_keys_work_end_to_end | Passed | 215 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCore_MultiDialectTests.Every_dialect_behind_one_api_in_one_process | Passed | 1539 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCore_PropertyNameTests.Entity_property_names_become_the_api_contract | Passed | 209 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCore_PropertyNameTests.Row_filters_and_expand_follow_the_renamed_columns | Passed | 2 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCore_SqliteEndToEndTests.Columns_the_entity_does_not_write_are_read_only_in_the_api | Passed | 5 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCore_SqliteEndToEndTests.Database_constraint_violations_map_to_the_engines_error_codes | Passed | 4 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCore_SqliteEndToEndTests.Delete_can_be_overridden_as_a_soft_delete | Passed | 74 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCore_SqliteEndToEndTests.Expand_and_after_read_hooks_work_on_extended_tables | Passed | 2 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCore_SqliteEndToEndTests.Handlers_can_forbid_an_update_based_on_the_original_row | Passed | 5 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCore_SqliteEndToEndTests.Hook_rejection_is_a_400_and_nothing_is_written | Passed | 2 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCore_SqliteEndToEndTests.Insert_outside_the_callers_row_filter_is_403_and_rolled_back | Passed | 2 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCore_SqliteEndToEndTests.Insert_runs_through_the_engine_and_the_handler | Passed | 12 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCore_SqliteEndToEndTests.OnCommitted_runs_once_after_commit_and_never_after_rollback | Passed | 86 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCore_SqliteEndToEndTests.Patch_changes_only_what_was_sent | Passed | 3 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCore_SqliteEndToEndTests.Reads_apply_role_row_filters_and_handler_filters_together | Passed | 2 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCore_SqliteEndToEndTests.Row_filters_protect_updates_and_deletes | Passed | 11 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCore_SqliteEndToEndTests.Side_writes_in_hooks_roll_back_with_the_api_write | Passed | 5 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCore_SqliteEndToEndTests.Tables_without_an_entity_keep_the_stock_instant_api | Passed | 4 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCoreGuardTests.Inheritance_mapping_is_rejected | Passed | 16 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCoreGuardTests.Owned_types_are_rejected | Passed | 75 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCoreGuardTests.Retrying_execution_strategy_is_rejected | Passed | 559 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCoreGuardTests.Unsupported_key_types_are_rejected | Passed | 6 ms |
| EzOdata.Entities.AspNetCore.Tests.EfCoreGuardTests.Value_converters_produce_a_startup_warning | Passed | 25 ms |
| EzOdata.Entities.AspNetCore.Tests.EscapeHatchTests.EfCore_escape_hatch_shares_the_write_transaction | Passed | 201 ms |
| EzOdata.Entities.AspNetCore.Tests.EscapeHatchTests.SimpleCRUD_escape_hatch_shares_the_write_transaction | Passed | 483 ms |
| EzOdata.Entities.AspNetCore.Tests.OpenApiTests.Openapi_server_urls_point_at_the_service | Passed | 493 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_CompositeKeyTests.Composite_keys_work_end_to_end | Passed | 165 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_MultiDialectTests.Every_dialect_behind_one_api_in_one_process | Passed | 1295 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_PropertyNameTests.Entity_property_names_become_the_api_contract | Passed | 167 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_PropertyNameTests.Row_filters_and_expand_follow_the_renamed_columns | Passed | 37 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_SqliteEndToEndTests.Columns_the_entity_does_not_write_are_read_only_in_the_api | Passed | 9 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_SqliteEndToEndTests.Database_constraint_violations_map_to_the_engines_error_codes | Passed | 3 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_SqliteEndToEndTests.Delete_can_be_overridden_as_a_soft_delete | Passed | 37 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_SqliteEndToEndTests.Expand_and_after_read_hooks_work_on_extended_tables | Passed | 3 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_SqliteEndToEndTests.Handlers_can_forbid_an_update_based_on_the_original_row | Passed | 6 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_SqliteEndToEndTests.Hook_rejection_is_a_400_and_nothing_is_written | Passed | 2 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_SqliteEndToEndTests.Insert_outside_the_callers_row_filter_is_403_and_rolled_back | Passed | 4 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_SqliteEndToEndTests.Insert_runs_through_the_engine_and_the_handler | Passed | 4 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_SqliteEndToEndTests.OnCommitted_runs_once_after_commit_and_never_after_rollback | Passed | 8 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_SqliteEndToEndTests.Patch_changes_only_what_was_sent | Passed | 7 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_SqliteEndToEndTests.Reads_apply_role_row_filters_and_handler_filters_together | Passed | 2 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_SqliteEndToEndTests.Row_filters_protect_updates_and_deletes | Passed | 3 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_SqliteEndToEndTests.Side_writes_in_hooks_roll_back_with_the_api_write | Passed | 11 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrud_SqliteEndToEndTests.Tables_without_an_entity_keep_the_stock_instant_api | Passed | 140 ms |
| EzOdata.Entities.AspNetCore.Tests.SimpleCrudNamingTests.UseNaming_maps_snake_case_tables | Passed | 475 ms |
| EzOdata.Entities.AspNetCore.Tests.StartupValidationTests.An_entity_that_does_not_match_the_database_stops_startup | Passed | 358 ms |
| EzOdata.Entities.AspNetCore.Tests.StartupValidationTests.Composite_key_parts_must_be_Required_for_SimpleCRUD | Passed | 8 ms |
| EzOdata.Entities.AspNetCore.Tests.StartupValidationTests.ExtendEzOData_requires_AddEzOData_first | Passed | 1 ms |
| EzOdata.Entities.AspNetCore.Tests.StartupValidationTests.Missing_engine_stops_startup | Passed | 5 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.An_incompatible_SimpleCRUD_is_rejected_with_a_clear_message | Passed | 0 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.Delegates_are_compiled_once_per_operation_and_type | Passed | 5 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.Describe_reads_the_mapping_from_SimpleCRUD_itself | Passed | 1 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.Each_engine_emits_its_own_dialect_in_one_process(dialect: MySQL, table: "`customers`", id: "`Id`", paging: "LIMIT 0,10") | Passed | 14 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.Each_engine_emits_its_own_dialect_in_one_process(dialect: PostgreSQL, table: "\"customers\"", id: "\"Id\"", paging: "LIMIT 10 OFFSET ((1-1) * 10)") | Passed | 69 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.Each_engine_emits_its_own_dialect_in_one_process(dialect: SQLite, table: "\"customers\"", id: "\"Id\"", paging: "LIMIT 10 OFFSET ((1-1) * 10)") | Passed | 9 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.Each_engine_emits_its_own_dialect_in_one_process(dialect: SQLServer, table: "[customers]", id: "[Id]", paging: "ROW_NUMBER() OVER(ORDER BY full_name)") | Passed | 14 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.Engines_are_lazy_singletons_per_dialect | Passed | 0 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.Exceptions_surface_unwrapped | Passed | 4 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.First_use_under_contention_creates_exactly_one_engine | Passed | 4 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.Isolated_engines_never_touch_the_process_wide_SimpleCRUD | Passed | 7 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.Keyed_DI_registrations_resolve_independent_dialects | Passed | 12 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.Naming_conventions_are_per_engine | Passed | 17 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.Overhead_versus_calling_SimpleCRUD_directly | Passed | 348 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.Sessions_commit_and_roll_back | Passed | 11 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.Several_custom_naming_engines_coexist | Passed | 14 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.Shared_engine_never_changes_global_state_implicitly | Passed | 1 ms |
| EzOdata.SimpleCrud.Tests.EngineTests.Why_isolation_is_needed_switching_SimpleCRUDs_dialect_leaves_stale_sql | Passed | 10 ms |
| EzOdata.SimpleCrud.Tests.MultiDatabaseTests.Every_database_at_once_in_one_process | Passed | 71 ms |
| EzOdata.SimpleCrud.Tests.MultiDatabaseTests.Full_crud_through_the_facade(kind: "mysql") | Passed | 117 ms |
| EzOdata.SimpleCrud.Tests.MultiDatabaseTests.Full_crud_through_the_facade(kind: "postgresql") | Passed | 126 ms |
| EzOdata.SimpleCrud.Tests.MultiDatabaseTests.Full_crud_through_the_facade(kind: "sqlserver") | Passed | 107 ms |
How this was measured
- Correctness first. Before any timing, every library reads the same 20-row page, then inserts, reads back, updates and deletes a row, on every database; the run stops on any difference.
- One database at a time. Each database runs in its own process with only its own container started (PostgreSQL 16, MySQL 8.4, SQL Server 2022, or Azure SQL Edge on ARM), so they do not compete for the Docker VM. SQLite is a local file in WAL mode.
- Library benchmarks use BenchmarkDotNet in process: 5 warm-up and 20 measured iterations of about 250 ms each, with the memory diagnoser. Each (library, database) case starts from a freshly created table with 5,000 rows.
- Same work for everyone. Dapper runs hand-written SQL and reads the new id back (RETURNING, OUTPUT or LAST_INSERT_ID). SimpleCRUD uses Get, GetListPaged, Insert, Update and Delete. EF Core uses a pooled DbContext factory with one context per operation, as ASP.NET Core does.
Insert 100is one transaction: SimpleCRUD inserts row by row and returns every id, EF Core batches, and Dapper's list form runs row by row without returning ids. - SimpleCRUD twice. The EzOdata facade runs SimpleCRUD in an isolated copy per dialect; SimpleCRUD's own static API is measured next to it on SQLite (its dialect is process-wide, so one database only) to show what the facade costs.
- EF Core × MySQL is not measured: there is no MySqlConnector-based EF Core 10 provider yet.
- SQLite has one writer. Its
concurrent-16row measures SQLite's lock retries (about 1.6 s per batch of 16 inserts), not the engines. - HTTP numbers come from an in-memory TestServer (no sockets), so they show the API layer plus the database, not the network. Engines are interleaved request by request so that none benefits from running later; allocations are whole-process bytes per request.
- Microbenchmarks on one machine: treat differences inside the confidence interval as equal, and rerun with
./try.sh --benchmarkon your own hardware.
Raw results: raw/ (BenchmarkDotNet tables and HTTP timings per database).