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

Project Only Needed Columns, Avoid Accidental Client Work, and Bound Result Sets

Measure the cost of query shape by comparing full tracked entities with bounded no-tracking projections, then expose accidental client work, row counts, payload width, materialization, and tracker overhead with generated SQL and repeatable local measurements.

Advanced150–190 minutesprojection + bounded-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 query cost as rows × columns × materialization/tracking work rather than as a LINQ-method slogan.

02

Compare a full tracked entity query with a no-tracking DTO/scalar projection and inspect the SQL difference.

03

Keep filtering, aggregation, ordering, and pagination server-side until an intentional client boundary.

04

Bound result sets deterministically and reconnect the Chapter 08 SQLite CreatedUtc ordering convention.

05

Measure command count, returned row count, tracker entries, elapsed time, and approximate payload instead of inventing performance numbers.

06

Diagnose the common ToList/AsEnumerable-too-early failure and repair it with an IQueryable pipeline.

1. Performance begins with the shape crossing the database boundary

ServiceHub has accumulated a rich WorkOrder aggregate: identifiers, customer data, summary, priority, timestamps, concurrency state, relationships, complex/owned values, JSON, and navigation collections. A dispatch-list endpoint does not need that whole graph. If it asks EF for tracked entities anyway, the database may send columns the endpoint never reads, EF must materialize entity instances and snapshots, and the change tracker must index each tracked identity.

A projection chooses the columns and result type needed by the use case. A bounded result set puts an explicit upper bound on returned rows. A client boundary such as ToList or AsEnumerable moves later LINQ work from the provider-translated IQueryable pipeline into .NET. None of those terms implies “always faster”; the lesson measures the consequences.

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.

2. Establish a deliberately expensive baseline

The first query is legal, simple, and often wasteful for a read-only list. Tracking is the default because the result is a normal entity query.

csharp · full tracked entity load
await using var db = factory.CreateDbContext();var full = await db.WorkOrders    .Where(w => w.Priority == WorkOrderPriority.High)    .OrderByDescending(w => EF.Property<DateTime>(w, "CreatedUtc"))    .ThenBy(w => w.Id)    .Take(50)    .ToListAsync(cancellationToken);Console.WriteLine($"Rows: {full.Count}");Console.WriteLine($"Tracked: {db.ChangeTracker.Entries<WorkOrder>().Count()}");

The mandatory SQLite path orders by the Chapter 04 shadow CreatedUtc : DateTime rather than OpenedUtc : DateTimeOffset, preserving the provider boundary established earlier. A list of 50 rows is deliberately bounded and ordered by a unique tie-breaker.

3. Project the read model EF actually needs to materialize

For a queue card, project only the fields the UI needs. Because no entity instance appears in the projection, normal entity tracking is not involved.

csharp · bounded DTO projection
public sealed record WorkOrderQueueItem(    int Id,    string Number,    string Customer,    WorkOrderPriority Priority,    DateTime CreatedUtc);var queueQuery = db.WorkOrders    .Where(w => w.Priority == WorkOrderPriority.High)    .OrderByDescending(w => EF.Property<DateTime>(w, "CreatedUtc"))    .ThenBy(w => w.Id)    .Take(50)    .Select(w => new WorkOrderQueueItem(        w.Id,        w.WorkOrderNumber,        w.CustomerName,        w.Priority,        EF.Property<DateTime>(w, "CreatedUtc")));Console.WriteLine(queueQuery.ToQueryString());var queue = await queueQuery.ToListAsync(cancellationToken);Console.WriteLine($"Tracked: {db.ChangeTracker.Entries().Count()}");

Expected SQLite SQL is narrow: it selects the identifier, work-order number, customer, priority, and created_utc column rather than every mapped scalar/JSON/owned field. The exact quoting and parameter names are provider output, not a portable SQL contract.

sql · representative SQLite translation
SELECT "w"."work_order_id", "w"."work_order_number",       "w"."customer_name", "w"."priority", "w"."created_utc"FROM "work_orders" AS "w"WHERE "w"."priority" = @__High_0ORDER BY "w"."created_utc" DESC, "w"."work_order_id"LIMIT @__p_1

4. Projection and AsNoTracking solve different problems

AsNoTracking() matters when EF materializes entity instances but you do not intend to persist changes through that context. A pure scalar/DTO projection already avoids tracking because there is no entity instance to track. A projection that contains an entity—such as new { Entity = w, NoteCount = w.Notes.Count }—can still track that entity unless you opt out.

Shape Entity materialized? Tracked by default? Typical use
WorkOrder Yes Yes unit-of-work edit
WorkOrder.AsNoTracking() Yes No read-only entity graph
DTO/scalar projection No No API/list/report
projection containing WorkOrder Yes Yes unless no-tracking mixed shape; inspect carefully

5. Keep aggregate work on the server when SQL can express it

Counting notes does not require loading every note. Chapter 09 showed aggregates and correlated subqueries; performance engineering now treats that translation as a data-movement decision.

csharp · server-side aggregate projection
var cards = await db.WorkOrders    .OrderByDescending(w => EF.Property<DateTime>(w, "CreatedUtc"))    .ThenBy(w => w.Id)    .Take(50)    .Select(w => new    {        w.Id,        w.WorkOrderNumber,        NoteCount = w.Notes.Count,        HasTechnician = w.AssignedTechnicianId != null    })    .ToListAsync(cancellationToken);

Inspect ToQueryString() and the command log. The important evidence is that the database returns one aggregate value per projected work order, not a materialized collection merely so .NET can call Count.

6. Failure case: materialize first, then discover the filter

csharp · wrong: the client boundary is too early
var all = await db.WorkOrders    .AsNoTracking()    .ToListAsync(cancellationToken); // database command executes herevar visible = all    .Where(w => w.Priority == WorkOrderPriority.High)    .OrderByDescending(w => w.WorkOrderNumber)    .Take(50)    .Select(w => new { w.Id, w.WorkOrderNumber })    .ToList();

This code may appear fine against ten development rows. With a large production table it transfers and materializes every work order before discarding most of them. The repair is not “use a faster collection”; it is to move the filter/order/limit/projection before the terminal operator so EF can translate them.

csharp · repair: keep the provider pipeline intact
var visible = await db.WorkOrders    .Where(w => w.Priority == WorkOrderPriority.High)    .OrderByDescending(w => w.WorkOrderNumber)    .ThenBy(w => w.Id)    .Take(50)    .Select(w => new { w.Id, w.WorkOrderNumber })    .ToListAsync(cancellationToken);

7. Measure without pretending one stopwatch proves causality

A local harness can compare the shapes, but disclose what it includes. Run Release mode, seed a deterministic row count, warm the model once, use the same database file/indexes, and record whether the OS/database cache is warm. Stopwatch captures end-to-end elapsed time; it does not separate translation, SQLite execution, materialization, garbage collection, and scheduler noise.

csharp · controlled measurement skeleton
static async Task<Sample> MeasureAsync(    IDbContextFactory<ServiceHubContext> factory,    Func<ServiceHubContext, CancellationToken, Task<int>> operation,    CancellationToken ct){    await using var db = await factory.CreateDbContextAsync(ct);    var before = GC.GetTotalAllocatedBytes(precise: true);    var sw = Stopwatch.StartNew();    var rows = await operation(db, ct);    sw.Stop();    var allocated = GC.GetTotalAllocatedBytes(precise: true) - before;    return new Sample(rows, sw.Elapsed, allocated,        db.ChangeTracker.Entries().Count());}public sealed record Sample(int Rows, TimeSpan Elapsed,    long ProcessAllocatedBytes, int TrackedEntries);

GC.GetTotalAllocatedBytes is process-wide, so keep the lab single-purpose and label the number approximate. For trustworthy microbenchmarks, Lesson 5 introduces BenchmarkDotNet and a fuller evidence protocol.

8. Lab: prove the data-movement difference

  1. Clone/reset the disposable ServiceHub SQLite file and seed at least 5,000 deterministic work orders with a known priority distribution.
  2. Run the full tracked query and the DTO projection after one warm-up each.
  3. Capture ToQueryString(), EF command count, returned rows, tracker-entry count, elapsed time, and approximate allocations.
  4. Repeat with 50 versus 500 rows to show that result cardinality can dominate small translation differences.
  5. Move a Where after ToListAsync, observe the wider/unbounded SQL, then repair it.
  6. Delete/reset only the disposable benchmark database; do not mutate the course migration history for a performance experiment.
Do not publish invented benchmark numbers

Keep the harness and evidence in the lesson, but use the learner machine to produce numbers. CPU, storage, antivirus, filesystem cache, database journal mode, logging, dataset distribution, and runtime patch can all change results.

9. Production judgment

Prefer projections for read models when the caller does not need entity behavior; use no-tracking entity queries when a real entity-shaped read is useful; and bound interactive result sets. Do not contort update workflows into DTO-only shapes just to avoid tracking, and do not assume a narrow SELECT fixes a missing index or poor predicate. Inspect SQL and the database plan. Chapter 17 next removes a smaller layer of CPU overhead: EF’s query compilation/cache lookup.

Check your understanding

  1. Why can a DTO projection be cheaper than a tracked entity load?
  2. Does AsNoTracking make an unbounded query safe?
  3. Why does Chapter 17 order mandatory SQLite labs by shadow CreatedUtc?
  4. What is wrong with calling ToList before filtering?
  5. Does ToQueryString prove a query is fast?
  6. What must accompany a benchmark result?
Review the answers

1. It can reduce selected columns, avoid entity materialization/snapshot tracking, and bound the amount of state crossing the database boundary.

2. No. It removes tracker work; it does not reduce rows or necessarily columns.

3. The course already documented Microsoft SQLite provider limitations for ordering/comparing DateTimeOffset, so CreatedUtc preserves server-side translation.

4. It executes the database query and moves later operators to the client, potentially transferring/materializing far more data.

5. No. It shows translation text, not runtime, plan quality, I/O, cache state, or network latency.

6. Dataset/cardinality, provider/database/runtime versions, build mode, cache/warmup state, query shape, tracking mode, indexes and machine/topology 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