Learnixo
Back to blog
AI Systemsintermediate

EF Core Performance — Query Optimization and Benchmarking

Optimize EF Core performance: compiled queries, AsNoTracking, connection pooling, bulk operations with ExecuteUpdate/ExecuteDelete, change tracker overhead, and profiling with MiniProfiler and query logs.

Asma Hafeez KhanMay 16, 20265 min read
EF CorePerformanceOptimizationASP.NET Core.NETDatabase
Share:𝕏

Performance Baseline

EF Core overhead vs raw ADO.NET:
  Simple SELECT → EF Core adds ~0.1ms overhead (negligible)
  Complex projection → EF Core is comparable to hand-written SQL
  Entity tracking → Change tracker overhead for large result sets
  
The N+1 problem and missing indexes matter more than EF Core overhead.
Profile first — optimize where the data shows it is needed.

AsNoTracking — Always for Read Queries

C#
// Default: EF Core tracks every loaded entity in the ChangeTracker
// Change tracker stores original values for comparison on SaveChanges
// For read-only queries, tracking is wasted memory and CPU

// Tracked (default) — use when you will update/delete the entity
var prescription = await _db.Prescriptions.FindAsync(id);
prescription.MarkDispensed();
await _db.SaveChangesAsync(ct);

// No tracking — use for all read queries
var summaries = await _db.Prescriptions
    .AsNoTracking()
    .Where(p => p.PatientId == patientId)
    .ToListAsync(ct);

// Global: disable tracking by default, enable when needed
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString)
        .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

// Then explicitly enable for write scenarios:
var entity = await _db.Patients
    .AsTracking()
    .FirstOrDefaultAsync(p => p.Id == id, ct);

Compiled Queries

C#
// EF Core compiles a query expression tree to a SQL query on first execution.
// For hot paths called thousands of times per second, pre-compile the query.

// Define compiled query as a static field
private static readonly Func<ApplicationDbContext, Guid, Task<Patient?>> GetPatientById =
    EF.CompileAsyncQuery((ApplicationDbContext db, Guid id) =>
        db.Patients
            .AsNoTracking()
            .FirstOrDefault(p => p.Id == new PatientId(id)));

// Use the compiled query — skips expression tree compilation
var patient = await GetPatientById(_db, patientId.Value);

// Benchmark: compiled queries are ~30% faster on hot paths
// (eliminates repeated query plan compilation overhead)

Bulk Operations — ExecuteUpdate / ExecuteDelete (.NET 7+)

C#
// Problem: loading entities to update them is wasteful
// BAD: loads 1,000 prescriptions into memory just to mark them expired
var expired = await _db.Prescriptions
    .Where(p => p.ExpiryDate < DateTime.UtcNow && p.IsActive)
    .ToListAsync(ct);

foreach (var p in expired)
    p.MarkExpired();

await _db.SaveChangesAsync(ct);

// GOOD: ExecuteUpdateAsync — UPDATE in one SQL statement, no entity loading
await _db.Prescriptions
    .Where(p => p.ExpiryDate < DateTime.UtcNow && p.IsActive)
    .ExecuteUpdateAsync(
        setters => setters.SetProperty(p => p.IsActive, false),
        ct);

// GOOD: ExecuteDeleteAsync — DELETE in one SQL statement
await _db.AuditLogs
    .Where(a => a.CreatedAt < retentionCutoff)
    .ExecuteDeleteAsync(ct);

// ExecuteUpdate/Delete bypass the change tracker and interceptors.
// Do not use for entities that need audit trail or soft delete via interceptors.

Chunked Bulk Processing

C#
// For processing large datasets without loading all into memory
// Use Chunk() (.NET 6+) to process in batches

await foreach (var batch in _db.Patients
    .Where(p => p.WardId == wardId)
    .AsNoTracking()
    .AsAsyncEnumerable()
    .Chunk(100))
{
    foreach (var patient in batch)
        await ProcessPatientAsync(patient, ct);

    // Can release memory between batches
}

// Or use chunked paging with keyset pagination for predictable memory use
var lastId = Guid.Empty;
while (true)
{
    var batch = await _db.Patients
        .Where(p => p.Id.Value > lastId)
        .OrderBy(p => p.Id)
        .Take(100)
        .AsNoTracking()
        .ToListAsync(ct);

    if (batch.Count == 0) break;

    foreach (var patient in batch)
        await ProcessAsync(patient, ct);

    lastId = batch.Last().Id.Value;
}

Connection Pooling

C#
// SQL Server connection pool is managed by SqlClient — EF Core uses it automatically.
// Default: min=0, max=100 connections per connection string.
// Configure via connection string:
"Server=sql-server;Database=clinical_db;Min Pool Size=5;Max Pool Size=200;Connection Timeout=30"

// DbContext lifetime: Scoped (one per HTTP request) is the default and correct.
// Never use Singleton DbContext — it causes thread safety issues.
// Never use Transient DbContext — too many pool checkouts.

// Monitor pool exhaustion:
// "Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool."
// This means Max Pool Size is too low, or connections are being held too long.

Query Plan Caching

C#
// EF Core caches query SQL per query expression shape.
// Parameterized queries share the same plan — good.
// Queries with different constant values get different cache entries — bad if many variations.

// BAD: each wardId value creates a new cache entry (if passed as a constant)
// (EF Core normally parameterizes correctly — this is an edge case with Contains)
var ids = new List<Guid> { id1, id2, id3 };  // list length matters for caching
var patients = await _db.Patients
    .Where(p => ids.Contains(p.Id.Value))  // EF Core parameterizes each element separately
    .ToListAsync(ct);

// For highly variable lists: consider temp table patterns or raw SQL with table-valued parameters

Profiling Tools

MiniProfiler (development):
  NuGet: MiniProfiler.AspNetCore.Mvc + MiniProfiler.EntityFrameworkCore
  Visit /profiler/results-index to see query counts, durations per request

EF Core logging:
  options.LogTo(Console.WriteLine, LogLevel.Information)
  options.EnableSensitiveDataLogging()   // shows parameter values

Application Insights:
  Dependency tracking shows individual SQL calls with durations
  Use QueryTagWith() to tag queries with feature context

SQL Server Profiler / Extended Events:
  Shows actual SQL reaching the database
  Identifies missing index suggestions
  Query Execution Plan: look for Table Scan → should be Index Seek

Production issue I've seen: A nightly batch job loaded 50,000 prescription records into memory, updated a status field on each, and called SaveChangesAsync() once. The change tracker held 50,000 original value snapshots in RAM — the process used 3GB of memory and the single SaveChangesAsync() call took 45 seconds to diff all 50,000 entities. Switching to ExecuteUpdateAsync() ran a single UPDATE SQL statement, used under 50MB of memory, and completed in 800ms.


Key Takeaway

Use AsNoTracking() for all read queries — the change tracker is pure overhead when you don't modify entities. Use compiled queries for hot paths called thousands of times per second. Use ExecuteUpdateAsync() and ExecuteDeleteAsync() for bulk operations — loading entities just to update them is wasteful. Profile with MiniProfiler in development; fix N+1 and missing indexes before tuning EF Core itself — they dwarf all other performance factors.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.