The N+1 Query Death Trap
This innocent-looking code is a performance killer:
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
:
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:
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:
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
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:
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:
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:
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:
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}
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:
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:
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
- Always use
Include
for related data - Don't rely on lazy loading in loops - Project early, project often - Only load the data you actually need
AsNoTracking()
only helps when returning entities - Projections make it redundant- Batch large
Contains()
operations - Avoid parameter limit issues - Keep query shapes consistent - Help EF's query compilation cache
- 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.