Entity Framework Core is the most popular ORM in .NET — and one
of the most misused. I have reviewed dozens of ASP.NET Core backends
where EF Core was the biggest performance bottleneck. Benchmarks show
25–50% faster query execution just from upgrading to .NET 10.
Here are 12 tips that will make a real difference in production.
1. Use AsNoTracking for Read-Only Queries
By default EF Core tracks every entity it loads. For read-only
queries this is completely wasted work.
// ❌ Bad — tracking enabled, wastes memory
var products = await context.Products
.Where(p => p.IsActive)
.ToListAsync();
// ✅ Good — no tracking, faster and less memory
var products = await context.Products
.AsNoTracking()
.Where(p => p.IsActive)
.ToListAsync();
Performance impact: 20–40% faster on large result sets.
2. Bulk Updates with ExecuteUpdateAsync
The old pattern generates one SQL UPDATE per entity. For 10,000
rows that is 10,000 round trips.
// ❌ Bad — 10,000 UPDATE statements
var users = await context.Users
.Where(u => u.LastLogin < DateTime.UtcNow.AddYears(-2))
.ToListAsync();
foreach (var user in users) user.IsActive = false;
await context.SaveChangesAsync();
// ✅ Good — ONE SQL UPDATE statement
await context.Users
.Where(u => u.LastLogin < DateTime.UtcNow.AddYears(-2))
.ExecuteUpdateAsync(u =>
u.SetProperty(x => x.IsActive, false));
3. Compiled Queries for Hot Paths
Every LINQ query goes through expression tree compilation.
Pre-compile it once and reuse on every call.
public static readonly Func<AppDbContext, Guid, Task<Product?>>
GetProductById =
EF.CompileAsyncQuery((AppDbContext ctx, Guid id) =>
ctx.Products
.AsNoTracking()
.FirstOrDefault(p => p.Id == id));
4. Select Only What You Need
Loading a full entity when you need two fields wastes bandwidth
and CPU.
// ❌ Bad — SELECT * FROM Products
var products = await context.Products
.Where(p => p.IsActive)
.ToListAsync();
// ✅ Good — SELECT Id, Name, Price FROM Products
var products = await context.Products
.AsNoTracking()
.Where(p => p.IsActive)
.Select(p => new ProductSummaryDto(p.Id, p.Name, p.Price))
.ToListAsync();
5. Fix the N+1 Query Problem
Loading 500 orders then lazy-loading each customer = 501 queries.
// ❌ Bad — 501 queries for 500 orders
var orders = await context.Orders.ToListAsync();
foreach (var order in orders)
Console.WriteLine(order.Customer.Name); // lazy load!
// ✅ Good — 1 query with JOIN
var orders = await context.Orders
.AsNoTracking()
.Include(o => o.Customer)
.ToListAsync();
6. Always Paginate — Never Load All Rows
var page = await context.Products
.AsNoTracking()
.OrderBy(p => p.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.Select(p => new ProductSummaryDto(p.Id, p.Name, p.Price))
.ToListAsync();
7. LeftJoin — New First-Class Operator in EF Core 10
No more GroupJoin + SelectMany workaround.
// ✅ Clean EF Core 10 LeftJoin
var results = context.Products
.AsNoTracking()
.LeftJoin(
context.Orders,
p => p.Id,
o => o.ProductId,
(p, o) => new
{
ProductName = p.Name,
OrderTotal = o != null ? o.Total : (decimal?)0
});
8. AsSplitQuery for Collection Includes
Multiple includes on collections causes cartesian explosion —
50 items × 3 payments = 150 rows instead of 53.
var orders = await context.Orders
.AsNoTracking()
.Include(o => o.OrderItems)
.Include(o => o.Payments)
.AsSplitQuery() // 3 clean SQL queries instead of 1 exploded JOIN
.ToListAsync();
9. Raw SQL for Complex Reports
var report = await context.Database
.SqlQuery<MonthlySalesDto>(
$"EXEC GetMonthlySales {startDate}, {endDate}")
.ToListAsync();
10. Add Database Indexes
All EF Core optimisations will not help if your table has no indexes.
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<Product>(e =>
{
e.HasIndex(p => p.Code).IsUnique();
e.HasIndex(p => new { p.CategoryId, p.IsActive, p.Price });
});
}
11. Native JSON Columns — New in EF Core 10
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<Blog>()
.OwnsOne(b => b.Details, d => d.ToJson());
}
12. Vector Search for AI Workloads — New in EF Core 10
var similarPosts = await context.BlogPosts
.AsNoTracking()
.OrderBy(b => EF.Functions.VectorDistance(
"cosine", b.Embedding, queryEmbedding))
.Take(5)
.ToListAsync();
Quick Reference Checklist
- ✅ AsNoTracking() on all read-only queries
- ✅ ExecuteUpdateAsync / ExecuteDeleteAsync for bulk ops
- ✅ Compiled queries for hot paths
- ✅ Project to DTOs with Select()
- ✅ Fix N+1 with Include() or projections
- ✅ Always paginate
- ✅ AsSplitQuery() for collection includes
- ✅ LeftJoin/RightJoin operators (EF Core 10)
- ✅ Index every WHERE, ORDER BY, JOIN column
- ✅ Upgrade to .NET 10 for free 25–50% baseline gain
Read the full guide with complete code examples on my blog →
https://codewithadnanshaukat.blogspot.com/2026/05/ef-core-10-performance-tips-complete-guide.html
Top comments (1)
Two production nuances worth adding:
Skip/Takebecomes progressively more expensive on deep pages because the database still walks the skipped rows. With a stable sort, keyset pagination—such asCreatedAt > lastCreatedAt, withIdas a tie-breaker and a matching composite index—usually scales better.I’d also treat compiled queries and
AsSplitQueryas measured options rather than defaults. EF already caches query shapes, while split queries exchange cartesian duplication for additional round trips and a possible consistency window. CapturingToQueryString(), the actual database execution plan, returned row counts, and p95 latency before and after each change usually identifies the real bottleneck. Which provider, dataset size, and benchmark produced the quoted 25–50% upgrade improvement?