Tech Diary / Data engineering / 001

EF Core Performance: A Practical Workflow for Production Systems

A measurement-led guide to faster EF Core queries and writes, with practical C# examples, tradeoffs and a production review checklist.

By Md. Mizanur Rahman ·

EF CoreC#SQLPerformance

The performance workflow

Find the cost. Reduce the work. Verify the result.

  1. 01MeasureTrace commands and inspect plans.
  2. 02Shape readsFilter, project and bound results.
  3. 03Load deliberatelyControl tracking and relationships.
  4. 04Plan for growthReview pagination and large inputs.
  5. 05Reuse wiselyEvaluate compilation and caching.
  6. 06Write efficientlyBound batches and context lifetimes.

EF Core performance is a system problem spanning LINQ, generated SQL, database design and application behavior. A checklist is a useful starting point, but production decisions require evidence about the workload.

The reference infographic groups improvements into six useful themes. This article expands that sequence into an engineering workflow, including where a recommendation can hurt or change correctness.

Scope: Modern EF Core with a relational provider. Examples are focused fragments using illustrative entities, not a runnable application. Verify translation against your exact provider and version. EF6 and legacy .NET Framework applications do not share all these APIs.

1. Establish evidence before changing code

A slow endpoint can be waiting on SQL, a connection, a lock, serialization or another service. Start with a representative request and follow it through the application. Record end-to-end p50 and p95 latency, database command count, command duration, rows returned and application allocations. Keep the input, dataset and concurrency level with the result.

Use query tags to identify important paths and EF logging to inspect commands. Simple logging is useful during diagnosis:

optionsBuilder.LogTo(
    Console.WriteLine,
    new[] { DbLoggerCategory.Database.Command.Name },
    LogLevel.Information);

// On the query being investigated:
var query = db.Orders.TagWith("OrderSearch");

Use structured logging and tracing in production. Avoid logging customer data or enabling sensitive parameter logging indiscriminately. A command timeout increase changes how long a request can wait; it does not explain the delay.

Inspect the execution plan using tools for your database. For SQL Server, examine the actual execution plan and logical reads; for PostgreSQL, use EXPLAIN (ANALYZE, BUFFERS) in a safe environment. ANALYZE executes the statement, so do not run write statements casually. Compare estimates with actual rows and look for expensive sorts, scans, spills and blocking. See Microsoft’s diagnosis guide.

2. Reduce the work each read requires

A list page should request a list-shaped result. Push filters into SQL, select only the fields the screen needs and apply a bounded page size before materialization. Calling ToListAsync() early turns later filtering into application work.

var pageSize = Math.Clamp(requestedPageSize, 1, 100);

var rows = await db.Orders
    .Where(o => o.TenantId == tenantId && o.IsActive)
    .OrderByDescending(o => o.CreatedAt)
    .ThenByDescending(o => o.Id)
    .Select(o => new {
        o.Id,
        o.CreatedAt,
        o.Total,
        CustomerName = o.Customer.Name
    })
    .Take(pageSize)
    .ToListAsync(cancellationToken);

This example uses illustrative entities and requires your normal tenant authorization. Projection can translate a related scalar into SQL without loading an entire customer graph. A scalar-only projection contains no entities to track. If you return entity instances for a read-only operation, consider AsNoTracking(); retain tracking when you plan to edit and save them. Identity resolution and projections make this more nuanced than a universal no-tracking rule.

Check indexes against the actual filter and ordering. A composite index beginning with tenant and status, followed by ordering columns, may suit this example; selectivity, write volume and the execution plan determine the final design. Adding every possible index increases maintenance and storage costs. Read efficient querying for the underlying principles.

3. Make relationship loading explicit

Count commands, not just the time of an individual command. Lazy loading can turn a loop into one initial query plus a query per entity. That pattern may look harmless on a developer database and become expensive over a network.

Use a projection for a screen or API contract. Use Include() when the operation needs related entities. Loading two sibling collections in one joined result can multiply rows: an illustrative order with ten items and five notes can contribute fifty joined rows. This is an explanation of row multiplication, not a benchmark.

var order = await db.Orders
    .AsNoTracking()
    .Where(o => o.TenantId == tenantId && o.Id == orderId)
    .Include(o => o.Items)
    .Include(o => o.Notes)
    .AsSplitQuery()
    .SingleOrDefaultAsync(cancellationToken);

AsSplitQuery() is a candidate for this graph, not a default performance switch. Separate commands add round trips and may observe changes between reads; stronger transaction isolation has its own cost. Compare single-query and split-query behavior with realistic cardinality and database latency. On versions before EF Core 10, split queries combined with Skip/Take require particular care to use fully unique ordering. Single versus split query guidance explains these tradeoffs.

4. Keep reads predictable as data grows

For a next/previous activity feed, keyset pagination avoids repeatedly skipping a growing number of rows. Use the same stable, unique ordering for both the query and its cursor. For descending date and ID order, the continuation condition moves below the last row:

var next = await db.Orders
    .Where(o => o.TenantId == tenantId &&
        (o.CreatedAt < lastCreatedAt ||
         (o.CreatedAt == lastCreatedAt && o.Id < lastId)))
    .OrderByDescending(o => o.CreatedAt)
    .ThenByDescending(o => o.Id)
    .Select(o => new { o.Id, o.CreatedAt, o.Total })
    .Take(pageSize)
    .ToListAsync(cancellationToken);

The first page omits the cursor predicate. Preserve timestamp precision in the cursor, validate its structure and reapply authorization and filters on every request. Mutable ordering fields can still move records between pages. Keyset pagination works well for sequential navigation; random jumps to a numbered page may justify offset pagination. There is no universal “page 5,000” failure threshold. See pagination guidance.

Large Contains() collections need provider-specific inspection. Translation can vary with EF Core version, compatibility settings and provider; it is not always one parameter per ID. Consider a table-valued parameter, temporary table or staging join when appropriate. Chunking is a fallback that adds round trips and requires deduplication, ordering and consistency decisions. Choose a bounded input contract before allowing an API to accept arbitrary millions of IDs.

5. Avoid repeating expensive work

First ask why a read repeats: duplicate UI requests, per-item lookups or a missing aggregate endpoint may be the real problem. Fixing request shape can remove work throughout the stack.

EF Core already caches query compilation by expression shape. Explicit compiled queries can reduce EF overhead on a measured hot path, but cannot repair a poor SQL plan. Keep parameters simple, reuse the delegate and benchmark the complete operation. Context pooling is another distinct option; it requires care with request and tenant state. Advanced performance topics covers both.

Result caching is an application design decision. A reference-data list with a short freshness tolerance differs from a payment status or balance. Define acceptable staleness, invalidation, ownership and failure behavior before adding Redis or an in-memory cache. Include tenant, permission scope and query inputs in keys where relevant; otherwise a fast cache can expose the wrong user’s result. Monitor hit rate, expiration bursts and memory use. These are design checks, not a claim that caching is appropriate for every query.

6. Choose the right write boundary

Calling SaveChangesAsync() for each item creates repeated work and fragments the unit of work. Accumulate a bounded group of related changes, then save at its intended transaction boundary:

foreach (var item in batch)
{
    db.AuditEntries.Add(new AuditEntry {
        EntityId = item.Id,
        Message = "Processed"
    });
}

await db.SaveChangesAsync(cancellationToken);

One SaveChanges call may still produce multiple SQL statements and provider batches. It is not a bulk-copy API. For large imports, use bounded chunks and short-lived contexts, and plan idempotency and restart behavior. Avoid tracking an entire unbounded file.

For a uniform update or deletion, EF Core 7+ provides set-based APIs on supported relational providers:

var affected = await db.Notifications
    .Where(n => n.TenantId == tenantId &&
                !n.IsRead && n.CreatedAt < cutoff)
    .ExecuteUpdateAsync(
        setters => setters.SetProperty(n => n.IsRead, true),
        cancellationToken);

This runs immediately and bypasses the change tracker. Do not call SaveChanges to execute it. Tracked instances can become stale; entity callbacks and logic implemented around SaveChanges will not automatically run. These APIs do not apply automatic optimistic concurrency checks: include an expected token in the predicate and check the affected count when required. Separate operations need an explicit transaction if they must succeed atomically. Validate business rules before substituting set-based SQL for domain operations. See ExecuteUpdate and ExecuteDelete.

7. Give background work its own context

A background worker should not retain a request-scoped context or share one context across parallel tasks. Create and dispose a context for each bounded unit of work with IDbContextFactory<AppDbContext>:

await using var db =
    await contextFactory.CreateDbContextAsync(cancellationToken);

var pending = await db.Jobs
    .Where(j => j.Status == "Pending")
    .OrderBy(j => j.Id)
    .Take(100)
    .ToListAsync(cancellationToken);

Await operations before reusing that context. If processing concurrently, each independent unit needs its own context. A factory handles lifetime, not distributed coordination: multiple workers still need a safe claim or lease mechanism to prevent processing the same job twice. Use database constraints, concurrency checks and idempotent handlers to enforce the intended behavior. The DbContext lifetime guide describes the threading rules.

8. Verify the improvement and keep the evidence

Consider a fictional order-history endpoint that loads whole orders, touches customer navigations in a loop and uses a deep offset. I would first capture its commands, introduce a narrow projection, check the supporting index and evaluate a cursor contract. I would test each change independently. I would investigate compiled queries only if profiling then showed meaningful EF-side overhead.

The acceptance record should make the result reproducible:

EvidenceWhat to record before and after
User experiencep50/p95 latency and errors under the same concurrency
Database workCommands, logical reads, duration, locks and execution plan
Application workAllocations, CPU, connection waits and response size
CorrectnessTenant isolation, stable pagination, concurrency and transaction behavior

These are measurement fields, not invented performance results. Keep the provider, EF Core version, data distribution and test inputs alongside them. Recheck under realistic network latency and skewed data, then watch production after rollout.

A pull-request checklist

  • Identify the slow request and retain its baseline.
  • Inspect generated SQL and the database plan.
  • Bound rows and columns; make relationship loading deliberate.
  • Review pagination, indexes and authorization together.
  • Explain cache freshness and write transaction boundaries.
  • Confirm context ownership and worker concurrency.
  • Attach comparable results and a rollout observation plan.

A useful performance change removes a demonstrated cost while preserving the behavior the system depends on. This workflow makes that reasoning visible in code reviews and repeatable across the team.

← All Tech Diary articles