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
- Why this matters in production
- How to spot issues fast
- Architecture map of the anti-patterns
- Quick summary of all 10 mistakes
- Mistake 1: N+1 Queries
- Mistake 2: Missing AsNoTracking
- Mistake 3: Loading Whole Entities
- Mistake 4: No Pagination
- Mistake 5: Client-Side Evaluation
- Mistake 6: Tracking Read-Only Data
- Mistake 7: Cartesian Explosion
- Mistake 8: No Compiled Queries
- Mistake 9: Missing Indexes
- Mistake 10: SaveChanges in a Loop
- Batch ops caveat: ExecuteUpdate and ExecuteDelete
- Decision matrix: what to fix first
- What EF Core 10 improves
- Troubleshooting checklist
- Production baseline checklist
- Wrap-up
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()));
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
Prefer scalable quality for sharing and zoom:
TL;DR
The 10 mistakes:
- N+1 queries
- Loading full entities instead of projections
- Missing AsNoTracking on read paths
- No pagination on list endpoints
- Client-side evaluation patterns
- Tracking read-only graphs
- Cartesian explosion from multiple collection Includes
- No compiled queries on hot paths
- Missing indexes on hot filters and joins
- 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();
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();
For write paths, opt back in explicitly:
var entity = await db.Orders
.AsTracking()
.FirstAsync(o => o.Id == id);
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();
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();
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();
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();
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();
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);
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 });
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();
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();
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:
- inspect SQL plans, not just LINQ
- verify index usage in production-like data
- confirm no hidden lazy-loading path in serialization
- verify query count per request in high-traffic endpoints
- 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)