
Advanced EF Core optimization techniques including query analysis, lazy loading pitfalls, change tracking, batch operations, and caching patterns for production APIs.
Your Name
Entity Framework Core is incredibly powerful for data access, but I've learned through countless production incidents that power without knowledge leads to performance disasters. Inefficient queries, improper change tracking, and missing indexes have brought down systems I've built. This article shares the optimization strategies that transformed my APIs from slow to lightning-fast.
Performance problems rarely happen by accident. They result from misunderstanding how EF Core executes queries and manages entities. Before optimizing, you need visibility.
One of my APIs experienced mysterious slowdowns during peak hours. A single endpoint that should return results in 100ms was taking 5 seconds. After investigation, I discovered a GetUsers() query that looked innocent was generating an N+1 query pattern—selecting one user, then querying the database for each user's orders, addresses, and preferences.
The first step in optimization is understanding what queries EF Core actually generates.
Log the actual SQL being generated:
services.AddDbContext<ApplicationDbContext>(options =>
options
.UseSqlServer(connectionString)
.EnableSensitiveDataLogging(isDevelopment)
.LogTo(Console.WriteLine, LogLevel.Information));
Or use query execution plans in SQL Server:
public class QueryAnalyzer
{
private readonly ApplicationDbContext _context;
public async Task<QueryPlan> AnalyzeQueryAsync<T>(
IQueryable<T> query) where T : class
{
var sql = query.ToQueryString();
using var connection = _context.Database.GetDbConnection();
await connection.OpenAsync();
using var command = connection.CreateCommand();
command.CommandText = $"SET STATISTICS IO ON; SET STATISTICS TIME ON; {sql}";
using var reader = await command.ExecuteReaderAsync();
// Analyze execution time and I/O statistics
return new QueryPlan { Sql = sql };
}
}
A classic and dangerous pattern:
// BAD - N+1 queries
var users = await _context.Users.ToListAsync(); // Query 1
foreach (var user in users)
{
var orders = await _context.Orders
.Where(o => o.UserId == user.Id)
.ToListAsync(); // Query N
user.Orders = orders;
}
// GOOD - Single query with eager loading
var users = await _context.Users
.Include(u => u.Orders)
.ThenInclude(o => o.LineItems)
.ToListAsync();
// GOOD - Projection to avoid loading unnecessary data
var userOrders = await _context.Users
.Where(u => u.IsActive)
.Select(u => new UserOrderSummary
{
UserId = u.Id,
UserName = u.Name,
OrderCount = u.Orders.Count,
TotalSpent = u.Orders.Sum(o => o.Amount)
})
.ToListAsync();
Different scenarios require different approaches:
public class OrderRepository
{
private readonly ApplicationDbContext _context;
// Strategy 1: Eager Loading - Load everything upfront
public async Task<Order> GetOrderWithDetailsAsync(int orderId)
{
return await _context.Orders
.Include(o => o.Customer)
.Include(o => o.LineItems)
.ThenInclude(li => li.Product)
.Include(o => o.Payments)
.FirstOrDefaultAsync(o => o.Id == orderId);
}
// Strategy 2: Explicit Loading - Load on demand
public async Task LoadPaymentsAsync(Order order)
{
await _context.Entry(order)
.Collection(o => o.Payments)
.LoadAsync();
}
// Strategy 3: Projection - Load only what you need
public async Task<OrderDetailDto> GetOrderDetailsAsync(int orderId)
{
return await _context.Orders
.Where(o => o.Id == orderId)
.Select(o => new OrderDetailDto
{
Id = o.Id,
OrderNumber = o.OrderNumber,
CustomerName = o.Customer.Name,
Items = o.LineItems.Select(li => new LineItemDto
{
ProductName = li.Product.Name,
Quantity = li.Quantity,
UnitPrice = li.UnitPrice
}).ToList(),
Total = o.LineItems.Sum(li => li.Quantity * li.UnitPrice)
})
.FirstOrDefaultAsync();
}
// Strategy 4: Split Queries - Multiple queries might be faster than one join
public async Task<Order> GetOrderWithDetailsSplitAsync(int orderId)
{
var order = await _context.Orders
.AsSplitQuery()
.Include(o => o.Customer)
.Include(o => o.LineItems)
.ThenInclude(li => li.Product)
.Include(o => o.Payments)
.FirstOrDefaultAsync(o => o.Id == orderId);
return order;
}
}
EF Core's change tracking is powerful but can consume significant memory.
public class ChangeTrackingDemo
{
public async Task DangerousQueryAsync()
{
// BAD - All users kept in memory with tracking enabled
var allUsers = await _context.Users.ToListAsync();
// Processing 100,000 users consumes significant memory
foreach (var user in allUsers)
{
// Memory accumulates
}
}
public async Task OptimizedQueryAsync()
{
// GOOD - No change tracking for read-only operations
var allUsers = await _context.Users
.AsNoTracking()
.ToListAsync();
// Process users without tracking overhead
}
}
// For bulk operations, batch process to avoid memory explosion
public async Task ProcessUsersInBatchesAsync()
{
const int batchSize = 1000;
int skip = 0;
bool hasMore = true;
while (hasMore)
{
var batch = await _context.Users
.AsNoTracking()
.Skip(skip)
.Take(batchSize)
.ToListAsync();
if (batch.Count == 0)
hasMore = false;
foreach (var user in batch)
{
await ProcessUserAsync(user);
}
skip += batchSize;
}
}
For complex operations, explicitly manage entity state:
public class OrderService
{
public async Task UpdateOrderAsync(Order order, List<LineItem> newItems)
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Update order
_context.Orders.Update(order);
// Remove old items
_context.LineItems.RemoveRange(order.LineItems);
// Add new items
foreach (var item in newItems)
{
item.OrderId = order.Id;
_context.LineItems.Add(item);
}
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}
For bulk operations, traditional EF Core is too slow. Use specialized libraries:
// Install: EFCore.BulkExtensions or EFCore.ColumnEncryption
public class BulkOperationService
{
private readonly ApplicationDbContext _context;
// Update 10,000 users
public async Task BulkUpdateUsersAsync(
List<User> users)
{
// Traditional approach would be extremely slow
// await _context.Users.UpdateAsync(users);
// Bulk operation - 100x faster
await _context.BulkUpdateAsync(users);
}
public async Task BulkInsertOrdersAsync(
List<Order> orders)
{
// Bulk insert - single operation instead of individual inserts
await _context.BulkInsertAsync(orders);
}
public async Task BulkDeleteAsync(
IQueryable<User> query)
{
// Bulk delete - executed on database server
await _context.Users.Where(u => !u.IsActive)
.BatchDeleteAsync();
}
}
A poorly indexed database makes EF Core queries slow regardless of your code.
public class Order
{
public int Id { get; set; }
public int UserId { get; set; }
public DateTime CreatedAt { get; set; }
public OrderStatus Status { get; set; }
public User User { get; set; }
}
// Configure indexes in DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Single column index
modelBuilder.Entity<Order>()
.HasIndex(o => o.UserId);
// Composite index for common query patterns
modelBuilder.Entity<Order>()
.HasIndex(o => new { o.UserId, o.Status, o.CreatedAt });
// Filtered index - only index active orders
modelBuilder.Entity<Order>()
.HasIndex(o => new { o.UserId, o.CreatedAt })
.HasFilter("[Status] = 'Active'");
// Included columns for covering queries
modelBuilder.Entity<Order>()
.HasIndex(o => o.CreatedAt)
.IncludeProperties(o => new { o.UserId, o.Amount });
}
public class OrderQueryOptimization
{
private readonly ApplicationDbContext _context;
// This query benefits from index on (UserId, Status, CreatedAt)
public async Task<List<Order>> GetRecentPendingOrdersAsync(
int userId,
DateTime since)
{
return await _context.Orders
.Where(o => o.UserId == userId
&& o.Status == OrderStatus.Pending
&& o.CreatedAt >= since)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
}
// This query uses the filtered index on Active orders
public async Task<int> GetActiveOrderCountAsync(int userId)
{
return await _context.Orders
.Where(o => o.UserId == userId && o.Status == OrderStatus.Active)
.CountAsync();
}
}
Database queries are the slowest operation. Cache aggressively.
public class ProductService
{
private readonly ApplicationDbContext _context;
private readonly IDistributedCache _cache;
private const string ProductCacheKey = "products";
private const int CacheDurationMinutes = 60;
public async Task<List<Product>> GetProductsAsync()
{
// Check cache first
var cached = await _cache.GetAsync(ProductCacheKey);
if (cached != null)
{
return JsonSerializer.Deserialize<List<Product>>(cached);
}
// Query database
var products = await _context.Products
.AsNoTracking()
.Where(p => p.IsActive)
.ToListAsync();
// Cache for future requests
await _cache.SetAsync(
ProductCacheKey,
JsonSerializer.SerializeToUtf8Bytes(products),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow =
TimeSpan.FromMinutes(CacheDurationMinutes)
});
return products;
}
// Invalidate cache when product changes
public async Task UpdateProductAsync(Product product)
{
_context.Products.Update(product);
await _context.SaveChangesAsync();
// Remove from cache
await _cache.RemoveAsync(ProductCacheKey);
}
}
The DbContext itself caches entities:
public async Task FirstLevelCacheExampleAsync()
{
// First query - hits database
var user1 = await _context.Users
.FirstOrDefaultAsync(u => u.Id == 1);
// Second query - returns cached instance
var user2 = await _context.Users
.FirstOrDefaultAsync(u => u.Id == 1);
// Same instance
Assert.Equal(user1, user2);
// To bypass cache
var user3 = await _context.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == 1);
}
// VERY BAD - Creates N queries
var users = await _context.Users.ToListAsync();
var userDtos = users.Select(async u => new UserDto
{
Id = u.Id,
Name = u.Name,
OrderCount = await _context.Orders
.CountAsync(o => o.UserId == u.Id) // Query for each user!
}).ToList();
// GOOD - Single query
var userDtos = await _context.Users
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name,
OrderCount = u.Orders.Count // In-memory aggregation
})
.ToListAsync();
// BAD - SQL Injection vulnerable and parameter not optimized
string searchTerm = "John";
var users = _context.Users
.FromSqlInterpolated($"SELECT * FROM Users WHERE Name LIKE '%{searchTerm}%'")
.ToList();
// GOOD - Parameterized query
var users = _context.Users
.FromSqlInterpolated($"SELECT * FROM Users WHERE Name LIKE CONCAT('%', {searchTerm}, '%')")
.ToList();
// BEST - Use LINQ
var users = await _context.Users
.Where(u => u.Name.Contains(searchTerm))
.ToListAsync();
// BAD - Lazy loading in API response
[HttpGet("{id}")]
public async Task<OrderDto> GetOrder(int id)
{
var order = await _context.Orders.FindAsync(id);
// Accessing navigation property triggers lazy load
var lineItems = order.LineItems; // Additional query!
return MapToDto(order);
}
// GOOD - Eager load before returning
[HttpGet("{id}")]
public async Task<OrderDto> GetOrder(int id)
{
var order = await _context.Orders
.Include(o => o.LineItems)
.FirstOrDefaultAsync(o => o.Id == id);
return MapToDto(order);
}
Implement comprehensive monitoring:
public class DbContextInterceptor : DbCommandInterceptor
{
private readonly ILogger<DbContextInterceptor> _logger;
public override async ValueTask<InterceptionResult<DbDataReader>>
ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
return await base.ReaderExecutingAsync(
command, eventData, result, cancellationToken);
}
public override async ValueTask<DbDataReader>
ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
var duration = eventData.Duration;
if (duration.TotalMilliseconds > 100)
{
_logger.LogWarning(
"Slow query detected ({DurationMs}ms): {Sql}",
duration.TotalMilliseconds,
command.CommandText);
}
return await base.ReaderExecutedAsync(
command, eventData, result, cancellationToken);
}
}
// Register the interceptor
services.AddDbContext<ApplicationDbContext>(options =>
options
.UseSqlServer(connectionString)
.AddInterceptors(new DbContextInterceptor(logger)));
After implementing these patterns across my projects:
Before deploying any EF Core application:
AsNoTracking() for read-only operationsEF Core is a powerful abstraction, but power requires responsibility. Understanding how it translates LINQ to SQL, managing change tracking effectively, and implementing strategic caching transform your applications from slow to blazing fast.
The optimization strategies I've shared have been battle-tested across production systems handling millions of requests daily. They're not theoretical—they're patterns that work.
What performance challenges have you encountered with EF Core? I'd love to hear about the optimizations you've discovered.