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

Compiled Queries, EF Query Cache Behavior, and When Compilation Helps

Understand EF Core query-shape caching and parameterization, use explicit compiled queries only on measured hot paths, and separate cache/translation CPU from database execution and network latency before claiming a win.

Advanced150–190 minutesquery-cache + compiled-query 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

Explain EF query compilation, internal shape-based caching, and database plan caching as separate mechanisms.

02

Use parameterized stable query shapes and recognize patterns that continuously defeat the EF query cache.

03

Create EF.CompileQuery/EF.CompileAsyncQuery delegates with simple scalar parameters and one EF model.

04

Use compiled delegates concurrently only across separate DbContext instances, preserving the no-concurrent-operation rule per context.

05

Measure compilation/cache lookup CPU separately enough to decide whether explicit compilation matters.

06

Reject compiled queries as a blanket optimization when database/network/materialization work dominates.

1. Most queries are already “compiled” and cached internally

When EF Core receives a LINQ expression tree, it translates the tree and prepares a database command/materializer. That work is cached by query shape. A later query with the same expression structure can reuse the cached compilation even when parameter values differ. The database may separately cache or prepare its own execution plan; that is an engine/driver concern, not EF’s query cache.

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.

Explicit compiled queries are therefore not the first optimization. They bypass EF’s expression-tree cache lookup and some associated processing. Microsoft’s own guidance describes that overhead as negligible in most applications compared with network I/O and database execution.

2. Stable parameterization gives EF one reusable shape

csharp · same shape, different values
static IQueryable<WorkOrder> QueueFor(    ServiceHubContext db,    WorkOrderPriority priority)    => db.WorkOrders        .AsNoTracking()        .Where(w => w.Priority == priority)        .OrderByDescending(w => EF.Property<DateTime>(w, "CreatedUtc"))        .ThenBy(w => w.Id);var high = await QueueFor(db, WorkOrderPriority.High).Take(25).ToListAsync(ct);var normal = await QueueFor(db, WorkOrderPriority.Normal).Take(25).ToListAsync(ct);

The captured method parameter becomes a SQL parameter, so the expression-tree shape is stable. Compare that with dynamically constructing an expression tree containing a new constant node for every request; EF sees different shapes and the database may also see different SQL text.

csharp · wrong dynamic-expression constant
var parameter = Expression.Parameter(typeof(WorkOrder), "w");var member = Expression.Property(parameter, nameof(WorkOrder.CustomerName));var constant = Expression.Constant(customerName); // different tree value embeddedvar body = Expression.Equal(member, constant);var predicate = Expression.Lambda<Func<WorkOrder, bool>>(body, parameter);var rows = await db.WorkOrders.Where(predicate).ToListAsync(ct);

When dynamic composition is actually necessary, prefer ordinary lambdas or construct parameterized expression shapes. A persistent EF Query Cache Hit Rate below 100% after warm-up is a diagnostic signal that dynamic shapes may be defeating the cache; it is not itself proof of user-visible slowness.

3. Compile the actual hot query, not an artificial wrapper

csharp · explicit compiled async query
private static readonly Func<    ServiceHubContext,    WorkOrderPriority,    int,    IAsyncEnumerable<WorkOrderQueueItem>> HotQueue =    EF.CompileAsyncQuery(        (ServiceHubContext db, WorkOrderPriority priority, int take) =>            db.WorkOrders                .AsNoTracking()                .Where(w => w.Priority == priority)                .OrderByDescending(w => EF.Property<DateTime>(w, "CreatedUtc"))                .ThenBy(w => w.Id)                .Take(take)                .Select(w => new WorkOrderQueueItem(                    w.Id,                    w.WorkOrderNumber,                    w.CustomerName,                    w.Priority,                    EF.Property<DateTime>(w, "CreatedUtc"))));var results = new List<WorkOrderQueueItem>();await foreach (var item in HotQueue(db, WorkOrderPriority.High, 50)    .WithCancellation(ct)){    results.Add(item);}

The compiled delegate is thread-safe and may be invoked concurrently on different context instances. It does not make one DbContext thread-safe. Chapter 08’s one-active-operation-per-context rule still applies.

4. Understand the limitations before turning queries into static fields

Constraint Why it matters
One EF model A compiled query is tied to the model it was compiled against. A context type configured with different models is not supported.
Simple scalar parameters Complex member/method parameter expressions are not supported by the compiled-query API.
Static query shape Highly dynamic user-selected filters/sorts cannot be reduced to one fixed compiled delegate without changing semantics.
Database cost remains The delegate still opens a connection, executes SQL, reads rows and materializes results.
Context rule remains Concurrent delegate calls need separate contexts.

5. Failure case: constant-shaped dynamic filters poison the cache

ServiceHub adds a configurable report builder. A developer uses the expression API and inserts request values as Expression.Constant. Every distinct value can force a new EF compilation and potentially a distinct database plan. CPU rises even though the SQL operation is logically identical.

csharp · repair with an ordinary parameterized lambda
IQueryable<WorkOrder> query = db.WorkOrders.AsNoTracking();if (!string.IsNullOrWhiteSpace(customerName)){    var value = customerName.Trim();    query = query.Where(w => w.CustomerName == value);}var rows = await query    .OrderBy(w => w.Id)    .Take(100)    .ToListAsync(ct);

The repair has the same request-dependent behavior without manually manufacturing a constant expression node.

6. Separate first-use, warm-cache, and compiled-delegate measurements

Run three phases in Release mode: (1) cold process/model/query first use; (2) normal LINQ after warm-up; (3) explicit compiled delegate after warm-up. Keep the database query tiny and indexed so database I/O does not swamp the CPU difference, but still acknowledge that the test executes a real SQLite command. Repeat many operations and report distributions rather than one stopwatch sample.

csharp · simple controlled loop outline
// Warm the model and the ordinary query first.await db.WorkOrders.Where(w => w.Id > 0).Take(1).ToListAsync(ct);for (var i = 0; i < iterations; i++){    await using var iterationDb = await factory.CreateDbContextAsync(ct);    var priority = (i & 1) == 0        ? WorkOrderPriority.High        : WorkOrderPriority.Normal;    await foreach (var _ in HotQueue(iterationDb, priority, 1).WithCancellation(ct))    {    }}

Creating a fresh context here prevents tracked-state growth from contaminating the loop. Lesson 4 later measures whether context pooling changes context-creation overhead.

7. What Query Cache Hit Rate can and cannot tell you

EF exposes metrics that include query-cache hit rate. After the application’s normal query shapes have executed, a healthy stable workload normally approaches 100%. A persistently lower value can point to dynamic shape churn. It does not identify slow SQL, missing indexes, lock waits, N+1 I/O, or network latency. Chapter 18 will wire those metrics into observability; here the metric is a hypothesis generator.

8. Lab decision record

  1. Capture an ordinary parameterized queue query and prove repeated SQL shape.
  2. Introduce a constant-shaped dynamic expression and record the churn in generated SQL/query-cache behavior.
  3. Repair the shape.
  4. Implement EF.CompileAsyncQuery for one stable, frequently executed queue query.
  5. Benchmark normal warm-cache versus explicit compiled delegate under the same provider/data/build settings.
  6. Keep the compiled path only if the measured CPU/latency gain is material to the workload; otherwise prefer ordinary LINQ for maintainability.

9. Production judgment

Compiled queries optimize a small client-side phase. They are valuable in high-throughput paths where query execution is already cheap and EF overhead is measurable. They are irrelevant to a table scan, lock wait, slow network, oversized result set, or N+1 pattern. Chapter 17 next moves earlier in the lifecycle—from per-query processing to the cost of constructing a large EF model at first use.

Check your understanding

  1. What does EF cache by query shape?
  2. What does EF.CompileAsyncQuery bypass?
  3. Can one compiled delegate run concurrently?
  4. Why are Expression.Constant nodes risky in dynamic queries?
  5. What does a low Query Cache Hit Rate suggest?
  6. When should you reject explicit compilation?
Review the answers

1. Its query compilation output, so structurally identical parameterized LINQ can reuse translation/materialization work.

2. The normal expression-tree cache lookup/processing path before execution; it does not bypass the database.

3. Yes across separate compatible DbContext instances; no concurrent operations on one context.

4. Different values can create different expression shapes, repeatedly defeating EF and database caches.

5. Possible query-shape churn after warm-up; it does not by itself identify the end-to-end bottleneck.

6. When measured gain is negligible or database/network/materialization work dominates and normal LINQ is clearer.

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