Back to Articles
August 19, 2025
5 min read

EF Core Query Optimization: The Traps That Kill Performance

Most EF Core performance issues aren't caused by complex scenarios—they're caused by seemingly innocent queries that generate awful SQL. Here are the critical optimization techniques that separate junior developers from pros

.NET.NETEF CoreOptimization

The N+1 Query Death Trap

This innocent-looking code is a performance killer:

CSHARP
1// 😱 WRONG - Generates N+1 queries 2var orders = await context.Orders.Take(100).ToListAsync(); 3foreach (var order in orders) 4{ 5 Console.WriteLine($"Customer: {order.Customer.Name}"); // Lazy loading triggers here 6} 7// Result: 1 query for orders + 100 queries for customers = 101 database hits

The Fix: Explicit loading with Include:

CSHARP
1// ✅ CORRECT - Single optimized query 2var orders = await context.Orders 3 .Include(o => o.Customer) 4 .Take(100) 5 .ToListAsync(); 6// Result: 1 query with JOIN

Pro tip: Use AsSingleQuery() vs AsSplitQuery() strategically:

CSHARP
1// For narrow results - single query with JOINs 2var orders = await context.Orders 3 .Include(o => o.Customer) 4 .Include(o => o.OrderItems) 5 .AsSingleQuery() // One complex query 6 .ToListAsync(); 7 8// For wide results - split into multiple queries 9var orders = await context.Orders 10 .Include(o => o.Customer) 11 .Include(o => o.OrderItems) 12 .ThenInclude(oi => oi.Product) 13 .AsSplitQuery() // Separate optimized queries 14 .ToListAsync();

AsNoTracking: Not Always Your Friend

Everyone knows AsNoTracking() improves performance, but timing matters:

CSHARP
1// 😱 WRONG - AsNoTracking applied too early 2var expensiveProducts = await context.Products 3 .AsNoTracking() // Applied here 4 .Where(p => p.Price > 1000) 5 .Select(p => new ProductDto 6 { 7 Name = p.Name, 8 Price = p.Price 9 }) 10 .ToListAsync(); 11// AsNoTracking is pointless here since Select creates new objects anyway
CSHARP
1// ✅ CORRECT - AsNoTracking where it matters 2var products = await context.Products 3 .Where(p => p.Price > 1000) 4 .AsNoTracking() // Only when returning entities 5 .ToListAsync(); 6 7// ✅ EVEN BETTER - Skip AsNoTracking with projections 8var productDtos = await context.Products 9 .Where(p => p.Price > 1000) 10 .Select(p => new ProductDto 11 { 12 Name = p.Name, 13 Price = p.Price 14 }) 15 .ToListAsync(); // No AsNoTracking needed

The Projection Performance Multiplier

Always project to exactly what you need:

CSHARP
1// 😱 WRONG - Loading entire entities 2var customers = await context.Customers 3 .Include(c => c.Orders) 4 .ThenInclude(o => o.OrderItems) 5 .Where(c => c.IsActive) 6 .ToListAsync(); 7// Loads massive amounts of unnecessary data 8 9// ✅ CORRECT - Project to specific needs 10var customerSummaries = await context.Customers 11 .Where(c => c.IsActive) 12 .Select(c => new CustomerSummaryDto 13 { 14 Name = c.Name, 15 Email = c.Email, 16 OrderCount = c.Orders.Count(), 17 TotalSpent = c.Orders.Sum(o => o.Total) 18 }) 19 .ToListAsync(); 20// Generates efficient GROUP BY query

The Contains() Performance Trap

Large Contains() calls can break SQL parameter limits:

CSHARP
1// 😱 PROBLEMATIC - Too many parameters 2var productIds = GetLargeListOfIds(); // 5000+ IDs 3var products = await context.Products 4 .Where(p => productIds.Contains(p.Id)) 5 .ToListAsync(); 6// Can hit SQL Server's 2100 parameter limit

The Fix: Batch your queries or use temp tables:

CSHARP
1// ✅ SOLUTION 1 - Batch processing 2const int batchSize = 1000; 3var allProducts = new List<Product>(); 4 5for (int i = 0; i < productIds.Count; i += batchSize) 6{ 7 var batch = productIds.Skip(i).Take(batchSize).ToList(); 8 var batchProducts = await context.Products 9 .Where(p => batch.Contains(p.Id)) 10 .ToListAsync(); 11 allProducts.AddRange(batchProducts); 12} 13 14// ✅ SOLUTION 2 - FromSqlRaw with table-valued parameter (SQL Server) 15var products = await context.Products 16 .FromSqlRaw("SELECT p.* FROM Products p INNER JOIN @ids i ON p.Id = i.Value", 17 new SqlParameter("@ids", CreateTableValuedParameter(productIds))) 18 .ToListAsync();

Async/Await: The Memory Leak Trap

Wrong async usage can exhaust your connection pool:

CSHARP
1// 😱 WRONG - Blocking async calls 2public List<Customer> GetCustomers() 3{ 4 return context.Customers.ToListAsync().Result; // Deadlock risk 5} 6 7// 😱 WRONG - Not disposing context properly 8public async Task<List<Customer>> GetCustomersWrong() 9{ 10 var context = new AppDbContext(); 11 return await context.Customers.ToListAsync(); // Context never disposed 12}
CSHARP
1// ✅ CORRECT - Proper async pattern 2public async Task<List<Customer>> GetCustomersAsync() 3{ 4 return await context.Customers.ToListAsync(); 5} 6 7// ✅ CORRECT - Using statements for explicit disposal 8public async Task<List<Customer>> GetCustomersWithDisposalAsync() 9{ 10 using var context = new AppDbContext(); 11 return await context.Customers.ToListAsync(); 12}

The Query Compilation Cache Trick

EF compiles LINQ expressions into SQL. Help it cache efficiently:

CSHARP
1// 😱 BAD - Prevents query caching 2public async Task<List<Product>> SearchProductsWrong(string searchTerm, bool includeInactive) 3{ 4 var query = context.Products.AsQueryable(); 5 6 if (!string.IsNullOrEmpty(searchTerm)) 7 query = query.Where(p => p.Name.Contains(searchTerm)); 8 9 if (!includeInactive) 10 query = query.Where(p => p.IsActive); 11 12 return await query.ToListAsync(); // Different query shapes hurt caching 13} 14 15// ✅ GOOD - Cache-friendly approach 16public async Task<List<Product>> SearchProducts(string searchTerm, bool includeInactive) 17{ 18 return await context.Products 19 .Where(p => (string.IsNullOrEmpty(searchTerm) || p.Name.Contains(searchTerm)) 20 && (includeInactive || p.IsActive)) 21 .ToListAsync(); // Same query shape, parameters vary 22}

Performance Monitoring Pro Tip

Tip: Always log your SQL to catch performance issues:

CSHARP
1// In your DbContext 2protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 3{ 4 optionsBuilder 5 .LogTo(Console.WriteLine, LogLevel.Information) 6 .EnableSensitiveDataLogging() // Only in development 7 .EnableDetailedErrors(); 8}

Key Takeaways

  1. Always use Include for related data - Don't rely on lazy loading in loops
  2. Project early, project often - Only load the data you actually need
  3. AsNoTracking() only helps when returning entities - Projections make it redundant
  4. Batch large Contains() operations - Avoid parameter limit issues
  5. Keep query shapes consistent - Help EF's query compilation cache
  6. Monitor your SQL - The generated queries tell the real performance story

Conclusion

Remember: EF Core is powerful, but it's not magic. Understanding the SQL it generates is the key to writing high-performance applications.


Want to dive deeper? Check out the Efficient Querying by Microsoft.