Entity Framework Core has become the preferred Object-Relational Mapper (ORM) for .NET developers because it simplifies database access while integrating seamlessly with ASP.NET Core. However, convenience comes with a cost. Poorly optimized EF Core queries can lead to excessive memory usage, unnecessary database round trips, slow API responses, and scalability issues.
EF Core 10 introduces several improvements, but achieving high performance still depends on how you design and use your data access layer. This article explores four key areas that have the greatest impact on production performance: change tracking, query filters, compiled queries, and bulk operations. You'll also learn when to use each feature and the trade-offs involved.
Understanding EF Core Performance
Where Does EF Core Spend Time?
Before optimizing, it's important to understand where performance bottlenecks originate.
Common sources include:
Change tracking overhead
Multiple database round trips
Inefficient LINQ queries
Loading unnecessary data
N+1 query problems
Frequent query compilation
Row-by-row updates and deletes
Framework upgrades alone won't solve these issues. Effective optimization starts with choosing the right EF Core features for each scenario.
Optimizing Change Tracking
Use Tracking Only When Needed
By default, EF Core tracks every entity it retrieves so that changes can be detected automatically during SaveChanges(). While this is useful for updates, it adds memory and CPU overhead for read-only operations.
var products = await context.Products
.AsNoTracking()
.ToListAsync();
Why Use AsNoTracking()?
AsNoTracking() tells EF Core not to monitor retrieved entities for changes. This reduces memory consumption and improves query execution for read-only scenarios such as dashboards, reports, and public APIs.
Use tracking only when you intend to modify and save entities.
When Should Tracking Be Enabled?
Tracking is appropriate when:
Editing records
Updating entities
Deleting data
Managing entity relationships
Avoid disabling tracking globally unless the application primarily performs read-only operations.
Using Global Query Filters
Automatically Filter Data
Many enterprise applications implement soft deletes or multi-tenancy. Without query filters, every query must include repetitive filtering logic.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.HasQueryFilter(p => !p.IsDeleted);
}
Why Use Global Query Filters?
Instead of adding Where(p => !p.IsDeleted) throughout the application, the filter is automatically applied to every query.
This improves consistency and reduces the risk of accidentally exposing deleted or unauthorized data.
Ignoring Query Filters
Occasionally, administrators may need to retrieve all records.
var products = await context.Products
.IgnoreQueryFilters()
.ToListAsync();
This allows privileged operations while preserving default application behavior.
Compiled Queries
Reduce Query Compilation Overhead
EF Core translates LINQ expressions into SQL before executing them. Frequently executed queries can benefit from compilation.
private static readonly Func<AppDbContext, int, Task<Product?>>
GetProductById =
EF.CompileAsyncQuery(
(AppDbContext context, int id) =>
context.Products
.FirstOrDefault(p => p.Id == id));
Using the compiled query:
var product = await GetProductById(context, productId);
Why Use Compiled Queries?
Normally, EF Core analyzes and translates the LINQ expression every time it executes.
Compiled queries cache this translation, making repeated execution more efficient.
They're particularly useful for:
Frequently accessed lookup tables
Authentication queries
Product catalogs
High-traffic APIs
Avoid compiling queries that execute only occasionally, as the additional complexity may not provide measurable benefits.
Efficient Bulk Operations
Avoid Updating Records One by One
Consider the following update:
foreach (var product in products)
{
product.IsActive = false;
}
await context.SaveChangesAsync();
For thousands of records, this approach becomes inefficient because every entity is loaded and tracked.
Instead, use EF Core's bulk update capabilities.
await context.Products
.Where(p => p.Stock == 0)
.ExecuteUpdateAsync(setters =>
setters.SetProperty(
p => p.IsActive,
false));
Why Is This Better?
ExecuteUpdateAsync() generates a single SQL statement that updates all matching rows directly in the database.
Benefits include:
Fewer database round trips
Lower memory usage
No entity tracking
Better scalability
The same principle applies to bulk deletes.
await context.Products
.Where(p => p.IsDeleted)
.ExecuteDeleteAsync();
This avoids loading entities into memory before deletion.
End-to-End Performance Optimization Workflow
Consider an e-commerce application exposing a product catalog.
Client Request
│
▼
ASP.NET Core API
│
▼
Application Service
│
▼
Compiled Query
│
▼
AsNoTracking()
│
▼
Global Query Filter
│
▼
SQL Database
Join the conversation! Your thoughts help the community grow.