Entity Framework Core (EF Core) is one of the most popular Object-Relational Mappers (ORMs) for .NET applications. It simplifies data access, reduces boilerplate code, and integrates seamlessly with ASP.NET Core. However, inefficient EF Core usage can lead to slow queries, excessive memory consumption, and unnecessary database round trips.
Many performance problems are not caused by EF Core itself but by how it is used. Simple changes such as disabling tracking for read-only queries, selecting only required columns, or avoiding the N+1 query problem can significantly improve application performance.
In this article, you'll learn practical EF Core 11 performance optimization techniques, understand when to apply them, and learn how to evaluate their impact using a structured benchmarking methodology.
Note: This article focuses on optimization techniques and benchmark methodology. It does not present fabricated benchmark numbers.
Why EF Core Performance Matters
Every database operation consumes resources.
Poorly optimized queries can result in:
Higher database CPU usage
Increased memory allocations
Longer response times
More network traffic
Lower application scalability
Optimizing data access is often one of the most effective ways to improve application performance.
Sample Entity
We'll use the following model throughout the examples.
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal Price { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
}
Use AsNoTracking for Read-Only Queries
By default, EF Core tracks every entity it loads.
var products = await context.Products
.ToListAsync();
If the data will not be modified, disable change tracking.
var products = await context.Products
.AsNoTracking()
.ToListAsync();
Why It Helps
AsNoTracking():
Reduces memory usage
Lowers CPU overhead
Improves query performance
Is ideal for APIs and reporting
Use tracking only when updates are required.
Select Only Required Columns
Avoid retrieving entire entities when only a few fields are needed.
Instead of:
var products = await context.Products
.ToListAsync();
Project the required columns.
var products = await context.Products
.Select(p => new
{
p.Id,
p.Name,
p.Price
})
.ToListAsync();
Smaller result sets reduce network traffic and memory consumption.
Avoid the N+1 Query Problem
Consider the following code.
var products = await context.Products
.ToListAsync();
foreach (var product in products)
{
Console.WriteLine(product.Category.Name);
}
This may execute one query for products and additional queries for each category.
Use eager loading instead.
var products = await context.Products
.Include(p => p.Category)
.ToListAsync();
This retrieves related data in a single query.
Use Filtered Includes
If only part of a navigation property is required, filter it.
var categories = await context.Categories
.Include(c => c.Products
.Where(p => p.Price > 100))
.ToListAsync();
Filtered includes reduce unnecessary data retrieval.
Use Pagination
Loading thousands of rows into memory is rarely necessary.
Instead of:
var products = await context.Products
.ToListAsync();
Use pagination.
var products = await context.Products
.OrderBy(p => p.Id)
.Skip(0)
.Take(20)
.ToListAsync();
Pagination improves response times and reduces memory usage.
Use Compiled Queries
Frequently executed queries can be compiled.
private static readonly Func<AppDbContext, int, Task<Product?>>
GetProduct =
EF.CompileAsyncQuery(
(AppDbContext db, int id) =>
db.Products.FirstOrDefault(p => p.Id == id));
Execute the compiled query.
var product = await GetProduct(context, 10);
Compiled queries reduce query compilation overhead for frequently executed operations.
Execute Bulk Updates
Instead of loading entities into memory before updating them:
var products = await context.Products.ToListAsync();
foreach (var product in products)
{
product.Price += 10;
}
await context.SaveChangesAsync();
Use ExecuteUpdateAsync().
await context.Products
.ExecuteUpdateAsync(p =>
p.SetProperty(
x => x.Price,
x => x.Price + 10));
This performs the update directly in the database.
Execute Bulk Deletes
Similarly, avoid loading rows before deleting them.
await context.Products
.Where(p => p.Price == 0)
.ExecuteDeleteAsync();
This generates a direct SQL DELETE statement.
Add Database Indexes
Frequently filtered columns should be indexed.
modelBuilder.Entity<Product>()
.HasIndex(p => p.CategoryId);
Indexes can significantly improve query performance for search and join operations.
Monitor Generated SQL
Inspect the SQL generated by EF Core.
var query = context.Products
.Where(p => p.Price > 500);
Console.WriteLine(query.ToQueryString());
Reviewing generated SQL helps identify unnecessary joins, filters, and inefficient queries.
End-to-End Query Flow
A typical optimized request follows these steps:
API receives a request.
EF Core generates SQL.
Database executes the query.
Only required columns are returned.
Change tracking is skipped for read-only data.
Results are serialized and returned.
Optimizing each stage improves overall application performance.

Join the conversation! Your thoughts help the community grow.