DEV Community

Cover image for 10 EF Core Performance Mistakes That Ship to Production
Majdi Zlitni
Majdi Zlitni

Posted on

10 EF Core Performance Mistakes That Ship to Production

If your API is fast with seed data but slows down in production, EF Core is rarely the root cause. It is usually the amplifier.

Most teams do not have one catastrophic query. They have many small query-shape decisions that compound into high P95 latency, lock pressure, and unnecessary database scale-up.

This article is a practical field guide to 10 EF Core performance mistakes I keep seeing in production .NET APIs, and the fixes that usually move the needle first.

In this post

What counts as a performance mistake?

An EF Core performance mistake is any data-access pattern that:

  • returns correct results in development
  • passes tests
  • but degrades badly under real data volume or concurrency

In other words: correctness is not enough. Query shape is architecture.

How to detect issues quickly

Before changing code, instrument first.

builder.Services.AddDbContext<AppDbContext>(options =>
        options.UseNpgsql(connectionString)
                     .LogTo(Console.WriteLine, LogLevel.Information)
                     .EnableSensitiveDataLogging(builder.Environment.IsDevelopment()));
Enter fullscreen mode Exit fullscreen mode

Use this as your first pass:

  • many SELECTs per request: likely N+1
  • huge result row count for small parent set: likely cartesian explosion
  • read endpoints with heavy tracking snapshots: missing AsNoTracking
  • list endpoints without LIMIT/TOP: missing pagination

The anti-pattern map

EF Core anti-pattern architecture map

Prefer scalable quality for sharing and zoom:

TL;DR

The 10 mistakes:

  1. N+1 queries
  2. Loading full entities instead of projections
  3. Missing AsNoTracking on read paths
  4. No pagination on list endpoints
  5. Client-side evaluation patterns
  6. Tracking read-only graphs
  7. Cartesian explosion from multiple collection Includes
  8. No compiled queries on hot paths
  9. Missing indexes on hot filters and joins
  10. SaveChanges inside loops

Plus one operational caveat: ExecuteUpdate and ExecuteDelete bypass the change tracker and should be coordinated with explicit transaction boundaries when mixed with tracked changes.

1) N+1 Queries

Problem

You load a parent set, then each navigation access triggers another database round trip.

Smell

  • Request count looks normal
  • SQL command count explodes

Fix

Use projection or eager loading intentionally.

var orders = await db.Orders
    .Where(o => o.CustomerId == customerId)
    .Select(o => new OrderDto
    {
        Id = o.Id,
        Total = o.Items.Sum(i => i.Price * i.Quantity),
        ItemCount = o.Items.Count
    })
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Use projection for API contracts. Use Include when domain logic needs full related aggregates in memory.

2) Missing AsNoTracking

Problem

Tracking every entity in read paths increases memory and CPU for change detection.

Fix

Default to no tracking on query endpoints.

var posts = await db.Posts
    .AsNoTracking()
    .Where(p => p.Published)
    .OrderByDescending(p => p.PublishedAt)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

For write paths, opt back in explicitly:

var entity = await db.Orders
    .AsTracking()
    .FirstAsync(o => o.Id == id);
Enter fullscreen mode Exit fullscreen mode

3) Loading Whole Entities

Problem

Fetching full rows when you only need 2-3 columns inflates payload and materialization cost.

Fix

Project to DTOs.

var users = await db.Users
    .AsNoTracking()
    .Select(u => new UserListItem(u.Id, u.DisplayName, u.AvatarUrl))
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Projection usually improves all of these at once:

  • less SQL payload
  • lower materialization cost
  • better API boundary control

4) No Pagination

Problem

Unbounded lists eventually become accidental load tests.

Fix

Apply deterministic ordering and pagination.

var page = await db.Articles
    .AsNoTracking()
    .OrderByDescending(a => a.CreatedAt)
    .Skip((request.Page - 1) * request.PageSize)
    .Take(request.PageSize)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Always cap server-side page size to prevent accidental heavy requests.

5) Client-Side Evaluation

Problem

Non-translatable logic forces EF Core to pull data and evaluate in memory.

Fix

Keep filters SQL-translatable, move complex logic after narrowing rows.

var filtered = await db.Payments
    .AsNoTracking()
    .Where(p => p.Status == PaymentStatus.Settled && p.Amount > 1000)
    .Select(p => new { p.Id, p.Amount, p.Reference })
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Rule: filter, sort, and paginate before materialization. Terminal operators like ToListAsync end translation and execute the query.

6) Tracking Read-Only Data

Problem

Long-running request flows with tracked entities increase memory pressure and GC churn.

Fix

Use one of these:

  • AsNoTracking for pure reads
  • AsNoTrackingWithIdentityResolution when graph identity consistency matters
var graph = await db.Orders
    .AsNoTrackingWithIdentityResolution()
    .Include(o => o.Customer)
    .Include(o => o.Items)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Use AsNoTrackingWithIdentityResolution when you need consistent identity for repeated references in the same result graph without full tracking overhead.

7) Cartesian Explosion

Problem

Multiple collection Includes on one query create row multiplication.

Fix

Use split queries when shape is unavoidable.

var orders = await db.Orders
    .AsNoTracking()
    .Include(o => o.Items)
    .Include(o => o.Events)
    .AsSplitQuery()
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Split queries trade one huge cross-product for multiple smaller round trips. Measure both modes on your dataset.

8) No Compiled Queries

Problem

Hot endpoints repeatedly pay query translation overhead.

Fix

Precompile stable high-frequency queries.

private static readonly Func<AppDbContext, Guid, Task<User?>> GetUserByIdCompiled =
    EF.CompileAsyncQuery((AppDbContext ctx, Guid id) =>
        ctx.Users.AsNoTracking().FirstOrDefault(u => u.Id == id));

var user = await GetUserByIdCompiled(db, userId);
Enter fullscreen mode Exit fullscreen mode

Apply compiled queries selectively to the highest-throughput read paths.

9) Missing Indexes

Problem

Critical filters run as scans instead of seeks.

Fix

Create indexes for common predicates and sort keys.

modelBuilder.Entity<Order>()
    .HasIndex(o => new { o.CustomerId, o.CreatedAt });
Enter fullscreen mode Exit fullscreen mode

Also verify generated SQL and query plan. If your API filter is index-friendly but still scanning, check collation mismatches and implicit conversions.

Index guidance for hot endpoints:

  • index frequent WHERE columns
  • index JOIN keys
  • index ORDER BY columns used with pagination
  • prefer composite indexes that match filter plus sort order

10) SaveChanges in a Loop

Problem

Each SaveChanges call is a round trip and transaction boundary.

Fix

Batch entity changes, then persist once.

foreach (var item in items)
{
    item.MarkProcessed();
}

await db.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

If this runs inside import or reconciliation jobs, moving SaveChanges out of the loop is often the fastest immediate win.

Batch operations caveat: ExecuteUpdate and ExecuteDelete

ExecuteUpdate and ExecuteDelete bypass the change tracker.

If you mix them with tracked updates in the same unit of work, wrap all operations in an explicit transaction to protect consistency.

await using var tx = await db.Database.BeginTransactionAsync();

await db.Orders
    .Where(o => o.Status == OrderStatus.Pending && o.CreatedAt < cutoff)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(o => o.Status, OrderStatus.Expired));

var audit = new CleanupAudit { RanAtUtc = DateTime.UtcNow, Affected = affectedRows };
db.CleanupAudits.Add(audit);

await db.SaveChangesAsync();
await tx.CommitAsync();
Enter fullscreen mode Exit fullscreen mode

Why this matters:

  • batch operators bypass change tracker
  • SaveChanges interceptors may not run for those operations
  • mixed write workflows should be explicit about consistency boundaries

Decision matrix: what to fix first

Symptom First fix Then
many SQL commands per request projection or Include cleanup check for lazy loading
large payload and high serialization cost projection DTOs AsNoTracking
high read-path memory AsNoTracking query-level projection
huge row count from multi-Include AsSplitQuery endpoint-specific projection
slow large updates/deletes ExecuteUpdate/ExecuteDelete transaction + audit strategy
list endpoint timeout pagination indexes + projection
hot endpoint CPU overhead compiled query caching strategy

What EF Core 10 helps with

EF Core 10 improves the baseline, but does not remove bad query-shape costs.

Useful platform gains to leverage:

  • improved LINQ ergonomics for complex joins
  • stronger behavior around split-query ordering consistency
  • runtime improvements in .NET 10 that help materialization paths

Bottom line: framework improvements help good patterns more than bad ones.

Troubleshooting checklist

If performance is still poor after applying fixes:

  1. inspect SQL plans, not just LINQ
  2. verify index usage in production-like data
  3. confirm no hidden lazy-loading path in serialization
  4. verify query count per request in high-traffic endpoints
  5. re-check page size limits and default sort columns

A practical baseline for production APIs

  • Default reads to no tracking
  • Project to DTOs, not entities
  • Always paginate collection endpoints
  • Measure query count per request
  • Add indexes for every top-traffic filter
  • Compile truly hot queries
  • Avoid SaveChanges inside loops
  • Treat batch operations as transaction-sensitive writes

EF Core performance is rarely about a single trick. It is about query-shape discipline applied consistently.

If your P95 latency is climbing, start here before scaling the database tier.

Wrap-up

Most EF Core incidents are not caused by obscure ORM bugs. They come from convenient defaults used past their safe limits.

If you adopt one rule, make it this: shape data intentionally at query time. Projection, no-tracking reads, pagination, and index-aware filters will solve most production regressions before you need heavier architecture changes.

Top comments (0)