Entity Framework Core – Best Practices
The example code used in this document can be found in my github repository: https://github.com/wagnersoftware/EfPerfBench
The example code uses .NET 8 with EF Core 8.0. For the benachmarking I use a local SQL Server database and DotNet BenchmarkDotNet library.
The outcome of these practices may vary depending on your specific use case and environment, so it’s always a good idea to measure performance in your own context.
1. Use AsNoTracking
Use AsNoTracking() for read-only scenarios to improve performance by disabling change tracking.
Function with tracking disabled:
public async Task Get_Customers_NoTracking()
{
var methodName = GetMethodName();
using var db = new AppDbContext(_options);
var data = await db.Customers
.TagWith(methodName)
.AsNoTracking()
.Take(_customerCount)
.ToListAsync();
}
Function with tracking enabled:
public async Task Get_Customers_Tracking()
{
var methodName = GetMethodName();
using var db = new AppDbContext(_options);
var data = await db.Customers
.TagWith(methodName)
.Take(_customerCount)
.ToListAsync();
}
What does it do?
AsNoTracking() tells EF Core not to track the entities returned from the query. This is beneficial in read-only scenarios where you do not need to update the entities, as it reduces memory usage and improves query performance.
Performance Impact:

You can see that using AsNoTracking() improves query performance by 81% and 68% of RAM usage compared to tracking enabled. The effect is more pronounced with larger datasets.
The downside is that you cannot update the entities returned from a no-tracking query without re-attaching them to the context, so use it judiciously.
You can set global no-tracking in Entity Framework Core by configuring the QueryTrackingBehavior in your DbContext or during registration:
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
You can enable the tracking for single queries by setting AsTracking(), e.g.:
return await _context.Set<T>().AsTracking().ToListAsync();
2. Avoid casting to IEnumerable
Avoid casting to IEnumerable when working with EF Core queries, as it can lead to inefficient in-memory operations.
Inefficient approach using IEnumerable:
public async Task Count_Customers_With_IEunumerable()
{
var methodName = GetMethodName();
using var db = new AppDbContext(_options);
var customersCount = db.Customers
.TagWith(methodName)
.ToList() // Materialize as IEnumerable
.Count(); //Executed in RAM
}
Efficient approach using IQueryable:
public async Task Count_Customers_With_IQueryable()
{
var methodName = GetMethodName();
using var db = new AppDbContext(_options);
var customersCount = await db.Customers
.TagWith(methodName)
.CountAsync(); //Executed in Database
}
What does it do?
When you cast to IEnumerable, EF Core retrieves all the data from the database and performs operations like counting in memory. This can lead to significant performance issues, especially with large datasets.
Performance Impact:

As you can see, using IQueryable for counting results in a much faster execution time (10x faster) and lower memory usage (90x times) compared to using IEnumerable. Always prefer IQueryable for operations that can be translated to SQL and executed on the database server.
Another example, is checking if any records exist with Any():
Inefficient approach using IEnumerable:
public async Task Customer_Exists_With_IEunumerable()
{
var methodName = GetMethodName();
using var db = new AppDbContext(_options);
var customerExists = db.Customers
.TagWith(methodName)
.ToList() // Materialize as IEnumerable
.Any(x => x.Id == 25000); //Executed in RAM
}
Efficient approach using IQueryable:
public async Task Customer_Exists_With_IQueryable()
{
var methodName = GetMethodName();
using var db = new AppDbContext(_options);
var customerExists = await db.Customers
.TagWith(methodName)
.AnyAsync(x => x.Id == 25000); //Executed in Database
}
Here is the performance impact:

3. Use efficient update and delete patterns (EF Core 7 and later)
When updating or deleting large numbers of records, consider using batch operations to minimize the number of database round-trips.
Inefficient approach – updating records one by one:
public async Task Customer_Update()
{
var methodName = GetMethodName();
using var db = new AppDbContext(_options);
var customers = await db.Customers.ToListAsync();
foreach (var customer in customers)
{
customer.Name = "Updated Name"; // Individual update
}
await db.SaveChangesAsync();
}
Efficient approach – using batch update:
public async Task Customer_Execute_Update()
{
var methodName = GetMethodName();
using var db = new AppDbContext(_options);
var customers = await db.Customers
.ExecuteUpdateAsync(x => x.
SetProperty(c => c.Name, "Updated Name")); // Batch update
}
What does it do?
The batch update method generates a single SQL UPDATE statement that updates all matching records in one go, rather than loading each entity into memory and updating them individually, which results in multiple database round-trips.
Performance Impact:

As shown, using batch updates reduces execution time (by 10% for this example) and memory usage (by 127x times) compared to updating records one by one. of course, the performance gain will be more significant with larger datasets.
It’s always good practise to avoid high RAM usage in applications.
The same applies to batch deletes:
Inefficient approach – deleting records one by one:
public async Task Customer_Delete()
{
using var db = new AppDbContext(_options);
var customers = await db.Customers
.TagWith(GetMethodName())
.ToListAsync();
var customerToRemove = customers.FirstOrDefault(c => c.Id == 25001); // Find customer in memory
if (customerToRemove != null)
{
db.Customers.Remove(customerToRemove);
}
await db.SaveChangesAsync();
}
Efficient approach – using batch delete:
public async Task Customer_Execute_Delete()
{
var methodName = GetMethodName();
using var db = new AppDbContext(_options);
var customerExists = await db.Customers
.TagWith(methodName)
.Where(x => x.Id == 25000)
.ExecuteDeleteAsync();
}
Performance Impact:

5. Optimize loading related data with Select and projection
Instead of using Include to load related data, consider using Select to project only the necessary fields. This can reduce the amount of data transferred from the database.
Inefficient approach using Include:
public async Task Ef_EagerLoading_ExplicitIncludes()
{
var methodName = GetMethodName();
using var db = new AppDbContext(_options);
var data = await db.Orders
.TagWith(methodName)
.Include(o => o.Customer) //Load entire Customer entity
.Where(o => o.CustomerId == 1000)
.ToListAsync();
}
Efficient approach using Select:
public async Task Ef_EagerLoading_ImplicitIncludes()
{
using var db = new AppDbContext(_options);
var data = await db.Orders
.TagWith(GetMethodName())
.Where(o =>; o.CustomerId == 1000)
.Select(o => new
{
CustomerName = o.Customer!.Name, //Only load necessary fields
o.Id,
o.CustomerId,
o.OrderDate,
}).ToListAsync();
}
What does it do?
Using Select allows you to specify exactly which fields you want to retrieve, reducing the amount of data transferred and improving performance, especially when dealing with large related entities.
Performance Impact:

In this example, using Select to project only the necessary fields doesn’t show a significant performance improvement in execution time, but it does reduce memory usage by 10% compared to using Include. The benefits become more pronounced with larger datasets or more complex related entities.
6. Use TagWith for easier query identification
Use TagWith to add comments to your queries for easier identification in logs and performance monitoring tools. This practice doesn’t directly impact performance but greatly aids in debugging and optimizing queries.
Example usage of TagWith:
public async Task Get_Customers_With_Tag()
{
var methodName = GetMethodName();
using var db = new AppDbContext(_options);
var data = await db.Customers
.TagWith(methodName) // Add a tag to the query
.Take(_customerCount)
.ToListAsync();
}
7. Use Cancelation Tokens for long-running queries
When executing long-running queries, consider using cancellation tokens to allow for graceful cancellation of the operation if needed. This will cancel the query execution on the database side as well, freeing up resources.
Example usage of CancellationToken:
public async Task Get_Customers_With_CancellationToken(CancellationToken cancellationToken)
{
var methodName = GetMethodName();
using var db = new AppDbContext(_options);
var data = await db.Customers
.TagWith(methodName)
.Take(_customerCount)
.ToListAsync(cancellationToken); // Pass the cancellation token
}
8. Use split queries for complex data retrieval
When retrieving complex data with multiple related entities, consider using split queries to reduce the size of the result set and improve performance.
Example without split queries:
public async Task Get_Customers_NoTracking_SingleQuery()
{
var methodName = GetMethodName();
using var db = new AppDbContext(_options);
var data = await db.Customers
.TagWith(methodName)
.AsNoTracking()
.Include(c => c.Orders)
.ThenInclude(o => o.Products)
.ToListAsync();
}
Example with split queries:
public async Task Get_Customers_NoTracking_SplitQuery()
{
var methodName = GetMethodName();
using var db = new AppDbContext(_options);
var data = await db.Customers
.TagWith(methodName)
.AsNoTracking()
.Include(c => c.Orders)
.ThenInclude(o => o.Products)
.AsSplitQuery() // Use split query
.ToListAsync();
}
What does it do?
Using AsSplitQuery() tells EF Core to execute separate SQL queries for each included related entity, which can reduce the amount of data transferred in a single query and improve performance in certain scenarios.
The downside is that it may result in more round-trips to the database, so it’s important to measure performance in your specific use case. In general this is more efficient when dealing with large datasets and multiple related entities.
Performance Impact:



Schreibe einen Kommentar