Entity Framework Core Best Practices for Production
Introduction
Entity Framework Core is powerful but easy to misuse. After years of production experience, these are the best practices that consistently prevent performance issues, simplify maintenance, and keep applications running smoothly at scale. Let's walk through each pattern with practical examples.
1. Always Use AsNoTracking for Read-Only Queries
By default, EF Core tracks every entity it loads from the database. This tracking enables change detection but adds memory overhead and CPU cost. For read-only scenarios — which make up the majority of most applications — disable it:
// Bad - tracks entities unnecessarily
var users = await _context.Users.ToListAsync();
// Good - much faster for read-only scenarios
var users = await _context.Users.AsNoTracking().ToListAsync();
For applications that are predominantly read-heavy, you can set AsNoTracking as the default at the DbContext level:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
Then explicitly opt in to tracking only when you need to update entities. This approach prevents the most common EF Core performance pitfall.
2. Use Projections Instead of Loading Full Entities
Loading entire entities when you only need a few columns wastes bandwidth and memory. Projections with Select tell EF Core to generate a SELECT with only the columns you need:
// Bad - loads all columns including large text fields
var users = await _context.Users.ToListAsync();
// Good - only loads what you need
var userDtos = await _context.Users
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name,
Email = u.Email
})
.ToListAsync();
This pattern is especially impactful when entities contain large columns like NVARCHAR(MAX) or binary data. A projection can turn a 50ms query into a 5ms query by eliminating unnecessary data transfer from the database. Projections also bypass the change tracker entirely, giving you the same benefit as AsNoTracking automatically.
3. Configure Indexes Properly
Missing indexes are the most common cause of slow queries. Define indexes in your OnModelCreating method for columns used in WHERE, ORDER BY, and JOIN clauses:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<BlogPost>()
.HasIndex(b => b.Slug)
.IsUnique();
modelBuilder.Entity<BlogPost>()
.HasIndex(b => b.PublishedDate);
// Composite index for common query patterns
modelBuilder.Entity<Order>()
.HasIndex(o => new { o.CustomerId, o.CreatedAt })
.HasDatabaseName("IX_Orders_Customer_Created");
// Filtered index for active records only
modelBuilder.Entity<Order>()
.HasIndex(o => o.Status)
.HasFilter("[Status] <> 'Cancelled'");
}
Review the generated SQL with ToQueryString() during development to verify your queries use the indexes you've defined.
4. Use Split Queries for Complex Includes
When a query includes multiple Include calls, EF Core generates a single SQL query with JOINs. This creates a "cartesian explosion" where the result set grows exponentially:
// Single query with cartesian explosion — can return massive result sets
var orders = await _context.Orders
.Include(o => o.Items)
.Include(o => o.Customer)
.AsSplitQuery()
.ToListAsync();
AsSplitQuery() generates separate SQL queries for each included navigation — more roundtrips, but much less data transfer. Use it when including multiple collection navigations. For single reference navigations, the default single query is usually fine.
5. Batch Operations for Bulk Updates and Deletes
EF Core 7+ supports ExecuteUpdateAsync and ExecuteDeleteAsync for bulk operations that bypass the change tracker entirely:
// Bad — loads every entity, modifies in memory, saves individually
var expiredOrders = await _context.Orders
.Where(o => o.ExpiresAt < DateTime.UtcNow)
.ToListAsync();
foreach (var order in expiredOrders)
order.Status = "Expired";
await _context.SaveChangesAsync();
// Good — single SQL UPDATE, no entities loaded
await _context.Orders
.Where(o => o.ExpiresAt < DateTime.UtcNow)
.ExecuteUpdateAsync(s =>
s.SetProperty(o => o.Status, "Expired")
.SetProperty(o => o.UpdatedAt, DateTime.UtcNow));
// Bulk delete
await _context.AuditLogs
.Where(l => l.CreatedAt < DateTime.UtcNow.AddYears(-1))
.ExecuteDeleteAsync();
For a table with 10,000 matching rows, ExecuteUpdate runs in milliseconds while the load-and-save approach can take minutes.
6. Handle Migrations Carefully
- Always review generated migrations before applying
- Use
dotnet ef migrations scriptfor production deployments - Never use
Database.Migrate()in production startup — it can cause race conditions with multiple instances
# Generate an idempotent migration script for production
dotnet ef migrations script --idempotent --output migrate.sql
# Review the SQL before running against production
# Apply via your CI/CD pipeline or database management tool
7. Use Connection Resiliency
Database connections can fail transiently, especially in cloud environments. Enable retry logic in your DbContext configuration:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(10),
errorNumbersToAdd: null);
sqlOptions.CommandTimeout(30);
}));
8. Monitor with Query Tags and Logging
Tag your queries so you can identify them in SQL Server's query store or monitoring tools:
var recentOrders = await _context.Orders
.TagWith("GetRecentOrders — Dashboard endpoint")
.Where(o => o.CreatedAt > DateTime.UtcNow.AddDays(-7))
.AsNoTracking()
.ToListAsync();
The tag appears as a SQL comment, making it easy to trace slow queries back to specific application code.
Conclusion
These practices prevent the most common performance and reliability issues in production EF Core applications. Start with AsNoTracking and projections — they deliver the biggest gains with the least effort. Then add proper indexes, split queries, and bulk operations as your application grows. Apply them consistently, and your EF Core applications will be production-ready from day one.
References
- EF Core performance tips — Official Microsoft performance guidance for Entity Framework Core
- EF Core querying data — Comprehensive guide to LINQ queries, tracking, and projections
- EF Core managing schemas and migrations — Best practices for database migrations in EF Core
- EF Core — executing raw SQL — Using raw SQL and bulk operations with ExecuteUpdate/ExecuteDelete
- EF Core GitHub repository — Source code, issue tracking, and release notes
Comments
Ajit Gangurde
Software Engineer II at Microsoft | 15+ years in .NET & Azure
Related Posts
Mar 14, 2026