Chapter 17 · Performance Engineering: Query Shape, Compiled Artifacts, Pooling, and Database Evidence

Benchmark with Database Execution Plans, Query Tags, Realistic Data Volumes, and End-to-End Latency

Build an evidence chain from LINQ and query tags through EF command timing to SQLite EXPLAIN QUERY PLAN and realistic end-to-end benchmarks, so performance conclusions distinguish ORM overhead from database work, cache state, concurrency, and network topology.

Advanced150–190 minutesexecution-plan + benchmark labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 mandatory baselinedotnet-ef 10.0.11 · SDK 10.0.400Last reviewed: August 2026

Learning outcomes

01

Design a repeatable performance experiment with realistic cardinality/distribution, warmup/cache disclosure, Release builds, and raw result retention.

02

Use TagWith/TagWithCallSite and EF command logs to correlate a LINQ operation with generated SQL.

03

Use SQLite EXPLAIN QUERY PLAN to verify access paths, while recognizing its plan output is engine-specific.

04

Separate translation/cache, materialization/tracking, driver/connection, database execution, and end-to-end latency in the evidence narrative.

05

Use BenchmarkDotNet 0.15.8 or a controlled console harness without publishing fabricated numbers.

06

Fix a semantically correct but inefficient ServiceHub query and verify the improvement with both SQL/plan evidence and end-to-end measurement.

1. A benchmark is an experiment, not a stopwatch screenshot

Performance engineering asks a causal question: which phase is expensive, and what evidence changes after the intervention? A useful report records application/runtime/EF/provider/database versions, build configuration, machine/container resources, database location, dataset size and skew, indexes/statistics, tracking/query shape, warmup, cache state, concurrency, connection pooling, logging, and network topology.

Frozen lab baseline

Mandatory baseline: .NET runtime 10.0.11, SDK 10.0.400, Microsoft.EntityFrameworkCore / Design / Sqlite and dotnet-ef 10.0.11. SQLite is the free local database. BenchmarkDotNet 0.15.8 is an optional free benchmark harness; the mandatory labs can run with a controlled Release-mode console harness. EF Core 11 preview behavior is not part of this course baseline.

The final chapter lab keeps SQLite local and free, so network latency is near-zero; that limitation must be disclosed. A production SQL Server/PostgreSQL benchmark across a network may have entirely different dominant costs.

2. Tag the query so the evidence chain has a stable name

csharp · tag the ServiceHub queue query
var query = db.WorkOrders    .TagWith("ch17.queue.high-priority.v1")    .AsNoTracking()    .Where(w => w.Priority == WorkOrderPriority.High)    .OrderByDescending(w => EF.Property<DateTime>(w, "CreatedUtc"))    .ThenBy(w => w.Id)    .Take(100)    .Select(w => new    {        w.Id,        w.WorkOrderNumber,        w.CustomerName,        w.Priority    });Console.WriteLine(query.ToQueryString());var rows = await query.ToListAsync(ct);

The tag appears as a SQL comment and helps match a LINQ operation to command logs and database tooling. Do not put secrets, user-entered values, tenant identifiers, or unbounded high-cardinality request data in tags.

3. Start with a semantically correct but planner-hostile predicate

The mapping already contains a composite index on priority/work-order number from Chapter 04. A prefix wildcard search on the number can make that access path less useful.

csharp · candidate inefficient query
var needle = "184";var slowShape = db.WorkOrders    .TagWith("ch17.number-search.contains")    .AsNoTracking()    .Where(w => w.Priority == WorkOrderPriority.High             && w.WorkOrderNumber.Contains(needle))    .OrderByDescending(w => w.WorkOrderNumber)    .Take(100)    .Select(w => new { w.Id, w.WorkOrderNumber });

Whether the engine scans or uses an index depends on the exact translation, collation, data distribution, SQLite version and indexes. Do not declare “Contains causes a scan” before asking the database.

4. Ask SQLite for its plan

Copy the generated SQL, substitute representative literal values only in a disposable diagnostic shell, and run EXPLAIN QUERY PLAN. Do not use literal substitution in application code.

sql · SQLite plan inspection
EXPLAIN QUERY PLANSELECT work_order_id, work_order_numberFROM work_ordersWHERE priority = 3  AND instr(work_order_number, '184') > 0ORDER BY work_order_number DESCLIMIT 100;

Representative outputs contain terms such as SCAN or SEARCH ... USING INDEX. SQLite’s plan format is not SQL Server’s execution plan XML or PostgreSQL EXPLAIN (ANALYZE, BUFFERS); provider/engine chapters use their own plan tools.

5. Repair query semantics before adding random indexes

If the business requirement is actually “numbers beginning with a known prefix,” express that narrower requirement and verify the plan. If arbitrary substring search is required, a B-tree may not be the right index/search technology. The performance fix follows product semantics, not an index superstition.

csharp · prefix-search alternative when semantics allow it
var prefix = "WO-2026-184";var repaired = db.WorkOrders    .TagWith("ch17.number-search.prefix")    .AsNoTracking()    .Where(w => w.Priority == WorkOrderPriority.High             && w.WorkOrderNumber.StartsWith(prefix))    .OrderByDescending(w => w.WorkOrderNumber)    .ThenBy(w => w.Id)    .Take(100)    .Select(w => new { w.Id, w.WorkOrderNumber });

Generate SQL and inspect EXPLAIN QUERY PLAN again. The acceptance criterion is not that the plan text “looks better”; it is that the plan/data-read evidence and end-to-end benchmark improve for the real distribution without changing required results.

6. Use BenchmarkDotNet for repeatable client-side measurement

BenchmarkDotNet 0.15.8 is the current stable release checked for this chapter and is free/open source. It is optional because database benchmarks are sensitive to process isolation and external state; a controlled console harness is also acceptable. Never run the benchmark in Debug configuration.

shell · optional benchmark project setup
dotnet new console -n ServiceHub.PerfLab -f net10.0cd ServiceHub.PerfLabdotnet add package BenchmarkDotNet --version 0.15.8dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 10.0.11dotnet run -c Release
csharp · benchmark skeleton
[MemoryDiagnoser]public class QueueBenchmarks{    private IDbContextFactory<ServiceHubContext> _factory = null!;    [GlobalSetup]    public async Task Setup()    {        _factory = PerfLabFactory.Create();        await using var db = await _factory.CreateDbContextAsync();        await db.WorkOrders.AsNoTracking().Select(w => w.Id).Take(1).ToListAsync();    }    [Benchmark(Baseline = true)]    public async Task<int> ContainsShape()    {        await using var db = await _factory.CreateDbContextAsync();        return await BuildContains(db).CountAsync();    }    [Benchmark]    public async Task<int> PrefixShape()    {        await using var db = await _factory.CreateDbContextAsync();        return await BuildPrefix(db).CountAsync();    }}

If the workload is dominated by SQLite file cache state, BenchmarkDotNet’s statistical rigor cannot make the external database state disappear. Record that state and complement it with database-side evidence.

7. Keep cold and warm cache experiments separate

Dimension Record explicitly
EF model/query cache cold process vs warmed model/query
OS/filesystem cache fresh/reboot/drop-cache if controlled vs warm repeated reads
SQLite page cache connection/database lifecycle and PRAGMAs
Connection pool Pooling True/False and whether connections are warm
JIT/PGO Release runtime warmup; NativeAOT not part of mandatory lab
Dataset row count, priority skew, string prefix distribution
Concurrency single-user baseline and chosen parallel load
Logging levels/interceptors enabled during measurement

Do not combine a cold-start improvement and a warm-query improvement into one percentage. They affect different phases and user experiences.

8. Decompose end-to-end latency instead of blaming “EF”

A command log duration includes provider/command execution around the database call; an application stopwatch also includes query construction, cache lookup/translation, context acquisition, materialization, serialization and surrounding code. A database execution plan explains access paths but not client materialization. Treat these as complementary measurements.

text · evidence record
Experiment: ch17.number-search.prefixApp: Release, .NET 10.0.11, EF Core/Sqlite 10.0.11DB: SQLite file local SSD, 100,000 rows, deterministic 20% High priorityIndexes: IX_work_orders_priority_number presentTracking: AsNoTracking projection { Id, WorkOrderNumber }Rows returned: <=100Warmup: 5; measured iterations: 30Connection pooling: TrueEF tag: ch17.number-search.prefixArtifacts: ToQueryString, EF command log, EXPLAIN QUERY PLAN, raw timingsConclusion: <fill from measured evidence; do not pre-write a percentage>

9. Failure case: optimize a microbenchmark while production waits on locks

A compiled query can shave client CPU while users still wait seconds on a database lock or network hop. A projection can reduce allocation while a missing/ineffective index reads most of the table. Conversely, an excellent database plan can still return too many rows and overload serialization. A production decision needs end-to-end correlation.

10. Lab acceptance criteria

  1. Seed a deterministic dataset large enough to expose plan/cardinality differences.
  2. Tag and capture SQL for baseline and repaired query shapes.
  3. Record SQLite EXPLAIN QUERY PLAN for both.
  4. Run controlled Release-mode measurements with disclosed warmup/cache/pooling settings.
  5. Verify result semantics are identical where the repair claims equivalence; if semantics changed, document the business requirement that permits it.
  6. Keep raw result files and machine/tool versions with the benchmark.
  7. Write a one-paragraph bottleneck conclusion that identifies what the evidence proves and what remains unmeasured.

11. Production judgment and bridge to observability

Performance engineering is complete only when the experiment can be repeated and the evidence points to a phase. Projection, compiled queries/models, pooling, indexing, and batching are workload-specific tools. Do not turn any of them into a global switch. Chapter 18 converts this laboratory discipline into production observability: structured EF logs, activities/metrics, interceptors, query tags, slow-query correlation, and dashboards without leaking sensitive data.

Check your understanding

  1. Why are query tags useful in a benchmark?
  2. Does EXPLAIN QUERY PLAN measure end-to-end latency?
  3. Why must cold and warm runs be reported separately?
  4. Can BenchmarkDotNet remove database-cache variability?
  5. What is wrong with publishing a speedup percentage before running the lab?
  6. What should the final conclusion identify?
Review the answers

1. They provide a stable correlation label between the LINQ operation, generated SQL/logs and database evidence.

2. No. It describes SQLite planner/access-path choices, not EF translation, network, materialization or serialization.

3. Model/query/JIT/database/OS caches materially change cost; combining them hides which phase improved.

4. No. It improves benchmark methodology, but external database and OS state still must be controlled/disclosed.

5. It fabricates evidence; results depend on machine, data distribution, indexes, provider/database state and topology.

6. The measured bottleneck phase, evidence supporting it, intervention effect, and remaining unmeasured assumptions.

Authoritative references

Performance behavior is workload-, provider-, and version-sensitive. Re-check these primary sources before carrying a result into production.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n