Entity Framework Core is an excellent ORM, but it's easy to write code that generates slow or excessive SQL queries without realizing it. After profiling several production applications, I've compiled the most common performance pitfalls and their solutions.
The N+1 Query Problem
This is the single most common performance issue I encounter. It happens when you load a collection and then access a navigation property on each item individually:
// BAD: Generates N+1 queries
var blogs = await context.Blogs.ToListAsync();
foreach (var blog in blogs)
{
Console.WriteLine($"{blog.Name}: {blog.Posts.Count} posts");
// Each iteration triggers a separate SQL query for Posts
}
The fix is to use eager loading with Include:
// GOOD: Single query with JOIN
var blogs = await context.Blogs
.Include(b => b.Posts)
.ToListAsync();
For more complex scenarios, use ThenInclude for nested navigation properties or AsSplitQuery() when a single query would generate a massive Cartesian product.
Tracking vs. No-Tracking Queries
By default, EF Core tracks every entity it loads. This means it keeps a copy in memory and checks for changes on SaveChanges(). If you're only reading data, this overhead is wasted:
// Read-only query - use AsNoTracking
var posts = await context.Posts
.AsNoTracking()
.Where(p => p.IsPublished)
.ToListAsync();
For read-heavy applications, consider setting AsNoTracking as the default behavior:
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
In my benchmarks, no-tracking queries are consistently 30-50% faster for read operations.
Select Only What You Need
Loading entire entities when you only need a few fields wastes memory and bandwidth:
// BAD: Loads all columns
var posts = await context.Posts.ToListAsync();
var titles = posts.Select(p => p.Title);
// GOOD: Only selects the Title column
var titles = await context.Posts
.Select(p => p.Title)
.ToListAsync();
For API responses, project directly into DTOs:
var summaries = await context.Posts
.Where(p => p.IsPublished)
.Select(p => new PostSummaryDto
{
Id = p.PublicId,
Title = p.Title,
PublishedAt = p.PublishedAt,
AuthorName = p.Author.DisplayName
})
.ToListAsync();
This approach reduces memory usage and avoids loading sensitive fields that shouldn't reach the API response.
Beware of Client-Side Evaluation
EF Core will silently evaluate expressions on the client if it can't translate them to SQL. This means loading entire tables into memory:
// This custom method can't be translated to SQL
var posts = await context.Posts
.Where(p => MyCustomFilter(p)) // Runs in memory!
.ToListAsync();
Enable the QueryClientEvaluationWarning to catch these cases during development. Better yet, ensure all filtering happens in translatable LINQ expressions.
Batch Your Operations
Individual SaveChanges() calls in a loop generate individual SQL statements. Use batching instead:
// BAD: One INSERT per iteration
foreach (var tag in newTags)
{
context.Tags.Add(tag);
await context.SaveChangesAsync();
}
// GOOD: Single batch INSERT
context.Tags.AddRange(newTags);
await context.SaveChangesAsync();
For bulk operations involving thousands of records, consider using ExecuteUpdate and ExecuteDelete introduced in EF Core 7:
await context.Posts
.Where(p => p.CreatedAt < cutoffDate)
.ExecuteDeleteAsync();
This generates a single DELETE FROM statement instead of loading and deleting entities one by one.
Use Compiled Queries for Hot Paths
If you have queries that execute thousands of times, compiled queries eliminate the overhead of expression tree translation:
private static readonly Func<AppDbContext, Guid, Task<Post?>> GetPostByPublicId =
EF.CompileAsyncQuery((AppDbContext context, Guid publicId) =>
context.Posts.FirstOrDefault(p => p.PublicId == publicId));
// Usage
var post = await GetPostByPublicId(context, publicId);
Profiling Your Queries
None of these tips matter if you can't see what SQL your application generates. Enable detailed logging during development:
options.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging()
.EnableDetailedErrors();
For production, integrate with Application Insights or use the EF Core interceptor pipeline to log slow queries above a threshold. Measure first, then optimize the queries that actually matter.

Comments (2)
Could you elaborate on this topic in a follow-up post?
Interesting thought, thanks for adding that.
Thanks for your comment — glad it helped!
This is exactly what I was looking for, thank you!
Leave a comment