If you've been working with Entity Framework Core for a while, you've probably hit that moment — the app is slow, the database is hammered, and you're staring at the profiler wondering what went wrong.
I've been there more times than I'd like to admit. In real projects, under real load, EF Core can either be your best friend or your worst enemy — and the difference is almost always a handful of avoidable mistakes.
In this post, I'm sharing 10 Entity Framework Core performance mistakes I've personally seen destroy app speed, with code examples and concrete fixes. No fluff — just patterns that actually come up in production .NET backends.
Why EF Core Performance Issues Are So Sneaky
The tricky thing about EF Core is that bad patterns look fine in development. Your test database has 50 rows. Everything loads fast. You deploy to production with 500,000 rows and suddenly response times balloon from 80ms to 4 seconds.
Most of these problems come down to not understanding what SQL EF Core generates. The ORM abstraction is powerful, but it can mask expensive operations if you're not paying attention.
My rule: always check the generated SQL for any non-trivial query. Tools like EF Core's built-in logging or MiniProfiler will save you from a lot of pain.
Mistake #1: The N+1 Query Problem
This is the classic, and it still gets people all the time. You load a list of entities, then access a navigation property inside a loop — and EF Core fires a separate query for every single item.
// BAD: N+1 queries
var orders = await _context.Orders.ToListAsync();
foreach (var order in orders)
{
Console.WriteLine(order.Customer.Name); // One extra query per order!
}
With 1,000 orders, that's 1,001 database round trips. I've seen this take a page from 200ms to 30+ seconds.
Fix it with eager loading:
// GOOD: Single query with JOIN
var orders = await _context.Orders
.Include(o => o.Customer)
.ToListAsync();
For complex object graphs with multiple levels of nesting, consider split queries introduced in EF Core 5:
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.OrderItems)
.AsSplitQuery()
.ToListAsync();
I covered the N+1 problem in depth in a dedicated post — it's worth a full read if this pattern is new to you.
Mistake #2: Forgetting AsNoTracking on Read-Only Queries
By default, EF Core tracks every entity it loads through the ChangeTracker. This lets you call SaveChanges() later and have it detect modifications automatically. It's a great feature — but it has overhead.
For read-only queries (list endpoints, reports, lookups), you're paying that tracking cost for nothing.
// BAD: Tracking enabled for a read-only list
var products = await _context.Products.ToListAsync();
// GOOD: No tracking overhead
var products = await _context.Products
.AsNoTracking()
.ToListAsync();
In my benchmarks on a medium-sized dataset (~10k rows), AsNoTracking() consistently shows 20–40% faster query execution for pure reads. On high-throughput APIs, this adds up fast.
You can also set AsNoTracking as the default for your DbContext if your use case is mostly reads:
// In DbContext constructor or OnConfiguring
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
Mistake #3: Selecting More Data Than You Need
This one is subtle but brutal at scale. You load full entity objects when you only need two or three fields. Every extra column is unnecessary data transferred over the wire and loaded into memory.
// BAD: Loading full Product entities just to display names
var products = await _context.Products.ToListAsync();
var names = products.Select(p => p.Name).ToList();
// GOOD: Project only what you need
var names = await _context.Products
.Select(p => new { p.Id, p.Name })
.ToListAsync();
This matters especially when your tables have nvarchar(max) columns (like descriptions or HTML content) that you're pulling into memory for no reason. Use projection with Select() to define exactly what SQL columns you need.
Mistake #4: Doing Filtering or Sorting Client-Side
EF Core will happily evaluate parts of your query in memory if it can't translate them to SQL. This is called client-side evaluation, and it means you're pulling all rows from the database and filtering in your application.
// BAD: Custom C# method that can't be translated to SQL
var users = await _context.Users
.Where(u => MyCustomHelper.IsActive(u)) // Client-side!
.ToListAsync();
EF Core will log a warning for this in recent versions, but it still executes. The fix is to ensure your predicates translate to SQL, or use raw SQL for complex cases:
// GOOD: Translatable predicate
var users = await _context.Users
.Where(u => u.IsActive && u.LastLoginDate > DateTime.UtcNow.AddDays(-30))
.ToListAsync();
When in doubt, use .ToQueryString() on your IQueryable to see what SQL will be generated before you execute.
Mistake #5: Lazy Loading in Production Code
Lazy loading sounds convenient — navigation properties load themselves on demand. But in practice, it's an N+1 problem factory.
I avoid enabling lazy loading entirely in production projects. If you've installed Microsoft.EntityFrameworkCore.Proxies and called UseLazyLoadingProxies(), I'd seriously reconsider for any app with real traffic.
// BAD: Lazy loading silently fires queries
services.AddDbContext<AppDbContext>(options =>
options.UseLazyLoadingProxies() // Danger zone
.UseSqlServer(connectionString));
Instead, be explicit. Use Include() for eager loading, or Entry().Reference().LoadAsync() for explicit loading when you genuinely need it conditionally.
Mistake #6: Not Paginating Large Result Sets
Loading thousands of rows into memory in a single call is a performance killer — and a memory leak waiting to happen.
// BAD: Loading everything
var allOrders = await _context.Orders.ToListAsync();
// GOOD: Paginate
var page = 1;
var pageSize = 50;
var orders = await _context.Orders
.OrderBy(o => o.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
Always paginate on the database side. Never load all records and paginate in memory. This is one of those mistakes that's harmless in dev with 100 rows but catastrophic in production with a million.
Mistake #7: Calling SaveChanges() Inside a Loop
This is painful to see in code reviews. Every SaveChanges() call is a database round trip. Calling it in a loop means one transaction per iteration.
// BAD: 1000 separate SaveChanges calls
foreach (var item in items)
{
_context.Products.Add(item);
await _context.SaveChangesAsync(); // One round trip per item!
}
// GOOD: Batch all adds, then save once
foreach (var item in items)
{
_context.Products.Add(item);
}
await _context.SaveChangesAsync(); // Single round trip
For bulk inserts of thousands of rows, consider EF Core's ExecuteUpdateAsync / ExecuteDeleteAsync (EF Core 7+) or a library like EFCore.BulkExtensions for raw bulk operations.
Mistake #8: Missing Database Indexes
EF Core doesn't automatically add indexes beyond primary keys and foreign keys. If you're filtering on a column frequently, you need to add an index manually — either via migration or fluent API.
// In your DbContext OnModelCreating
modelBuilder.Entity<Order>()
.HasIndex(o => o.CustomerId); // Index foreign key
modelBuilder.Entity<User>()
.HasIndex(u => u.Email)
.IsUnique(); // Index + unique constraint
A query that scans a table of 2 million rows vs. one that hits an index is the difference between a 5-second timeout and a 5ms response. I always check the execution plan of slow queries in SQL Server Management Studio before touching the EF code.
Mistake #9: Ignoring DbContext Lifetime in DI
If you register your DbContext as a singleton (instead of scoped), you'll hit concurrency issues and memory leaks because the ChangeTracker accumulates tracked entities across requests indefinitely.
// BAD: Singleton DbContext
services.AddDbContext<AppDbContext>(options => ..., ServiceLifetime.Singleton);
// GOOD: Scoped (the default, and correct)
services.AddDbContext<AppDbContext>(options => ...);
// AddDbContext defaults to Scoped lifetime
For background services that run outside of a request scope, use IDbContextFactory<T> to create short-lived DbContext instances per operation:
public class MyBackgroundService : BackgroundService
{
private readonly IDbContextFactory<AppDbContext> _factory;
public MyBackgroundService(IDbContextFactory<AppDbContext> factory)
=> _factory = factory;
protected override async Task ExecuteAsync(CancellationToken ct)
{
await using var context = await _factory.CreateDbContextAsync(ct);
// Use context here — disposed after this block
}
}
Mistake #10: Not Using Compiled Queries for Hot Paths
Every time EF Core executes a LINQ query, it has to compile it to SQL. For queries that run hundreds of times per second, this compilation overhead is measurable.
EF Core's compiled queries let you pre-compile a query once and reuse the SQL plan:
// Define once as a static field
private static readonly Func<AppDbContext, int, Task<Order?>> GetOrderById =
EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
ctx.Orders
.AsNoTracking()
.Include(o => o.OrderItems)
.FirstOrDefault(o => o.Id == id));
// Use it in hot-path code
var order = await GetOrderById(_context, orderId);
I use compiled queries in any endpoint that gets hammered — things like product lookups, user authentication checks, or catalog searches. The performance difference is real, especially when combined with AsNoTracking.
Key Takeaways
- Always use
Include()or projection to avoid N+1 queries — never rely on lazy loading in production. - Add
AsNoTracking()to every read-only query; it's one of the cheapest wins in EF Core performance. - Project with
Select()to fetch only the columns you actually need — don't pull full entities for display-only data. - Ensure your predicates are SQL-translatable — watch for client-side evaluation warnings in your logs.
- Never call
SaveChanges()inside a loop — batch your operations and save once. - Index your filter columns in the database; EF Core won't do this for you automatically.
- Use
IDbContextFactory<T>in background services to avoid concurrency and memory issues. - Consider compiled queries for high-frequency hot paths to eliminate per-call LINQ compilation overhead.
Conclusion
Entity Framework Core performance problems are sneaky — they hide in development and explode in production. But once you know the patterns, most of them are straightforward to fix.
The biggest shift I've made over the years is treating EF Core as a SQL generator, not a magic data layer. I check the generated SQL. I profile before I optimize. I make explicit decisions about tracking, loading strategy, and projection on every significant query.
If you're battling a slow .NET app and EF Core is in the stack, start with these 10 mistakes. Odds are good you'll find at least two or three lurking in your codebase right now.
Have a performance horror story — or a fix I didn't cover? Drop a comment below or share this post with your team. And feel free to explore more .NET deep-dives on steve-bang.com.
FAQ
Q: What is the most common Entity Framework Core performance mistake?
A: The N+1 query problem is the most frequent offender. It happens when navigation properties are accessed in a loop without eager loading, triggering one extra database query per entity. Use Include() or AsSplitQuery() to eliminate it.
Q: When should I use AsNoTracking in EF Core? A: Use it on any query where you won't be updating the result — API list endpoints, reports, lookups. It skips ChangeTracker overhead and can improve read performance by 20–40% in typical scenarios.
Q: Does lazy loading hurt EF Core performance? A: Significantly. Lazy loading silently fires individual queries whenever you access a navigation property. In loops or deeply nested relationships, this creates a cascade of unintended round trips. Use eager or explicit loading instead.
Q: Should I use Select() instead of loading full entities in EF Core? A: Yes, always project to only what you need. Loading full entities with large columns (text, blobs) when you only need an ID and a name wastes both database I/O and application memory.
Q: How can I debug slow EF Core queries in production?
A: Enable EF Core's simple logging or use optionsBuilder.LogTo(Console.WriteLine) in development. In production, Application Insights, Serilog structured logging, or SQL Server Query Store are your best tools for finding slow queries.
Related Resources
- N+1 Problem in Entity Framework Explained: Beginner's Guide to Better Performance — Deep dive into the most common EF Core performance trap with step-by-step fixes.
- Top 10 Mistakes Every Developer Should Avoid While Using Entity Framework — Broader list of EF anti-patterns from beginner to intermediate level.
- Entity Framework Core in .NET: The Complete Beginner's Guide — If you want to revisit the EF Core fundamentals before diving deep on performance.
- CancellationToken in .NET: Best Practices to Prevent Wasted Work — Pairs well with EF Core async queries — learn how to cancel long-running database calls gracefully.
- Mastering Row-Level Security (RLS) in SQL Server — Goes hand-in-hand with EF Core if you're working with multi-tenant data filtering at the database level.
