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
Here's how the request flows:
A client requests available products.
The API forwards the request to the application service.
A compiled query retrieves product data.
AsNoTracking()eliminates unnecessary tracking overhead.Global query filters automatically exclude soft-deleted products.
Optimized SQL is executed.
Results are returned with minimal overhead.
Each optimization contributes to a more efficient request pipeline without increasing application complexity.
Choosing the Right Optimization
| Feature | Best Used For | Avoid When |
|---|---|---|
| AsNoTracking | Read-only queries | Updating entities |
| Query Filters | Soft delete, multi-tenancy | Administrative reporting requiring all records |
| Compiled Queries | Frequently executed queries | Rarely executed queries |
| ExecuteUpdateAsync | Large updates | Entity validation is required |
| ExecuteDeleteAsync | Large deletes | Business rules require entity loading |
Choosing the right optimization depends on the application's workload rather than applying every technique everywhere.
Best Practices
Use
AsNoTracking()for read-only operations.Keep LINQ queries simple and readable.
Retrieve only required columns using projections.
Use pagination for large datasets.
Prefer bulk operations for mass updates and deletes.
Monitor generated SQL during development.
Benchmark performance before and after optimization.
Keep business logic outside the data access layer.
Common Mistakes
A common mistake is enabling tracking for every query. While convenient, unnecessary tracking increases memory usage and reduces throughput.
Another issue is loading entire entities when only a few properties are required. Projecting only the necessary fields reduces network traffic and query execution time.
Developers also tend to optimize prematurely. Profile the application first to identify actual bottlenecks before introducing compiled queries or other advanced optimizations.
Testing and Validation
Performance optimizations should never compromise correctness.
Before deploying changes:
Run unit tests for repository methods.
Validate generated SQL.
Perform integration testing against a real database.
Test bulk operations on staging data.
Verify soft-delete behavior.
Benchmark frequently executed queries.
Perform load testing using production-like workloads.
Testing ensures that performance improvements do not introduce functional regressions.
Performance Considerations
While the techniques discussed improve performance, they should be applied selectively.
Keep these recommendations in mind:
Use asynchronous database operations.
Avoid N+1 query patterns.
Use projections instead of retrieving entire entities.
Monitor slow SQL queries.
Keep database indexes optimized.
Cache frequently requested reference data where appropriate.
Measure memory allocation during load testing.
Remember that database design often has a greater impact on performance than ORM configuration alone.
Security Considerations
Database performance should never come at the expense of security.
Follow these practices:
Validate all user input before executing queries.
Continue using LINQ to benefit from parameterized SQL generation.
Restrict administrative operations that bypass query filters.
Apply authorization before executing bulk updates or deletes.
Audit large data modification operations.
Protect connection strings using secure configuration providers.
Use least-privilege database accounts.
Security and performance should always be optimized together rather than independently.
Troubleshooting
Queries Are Still Slow
Inspect the generated SQL and review execution plans. The bottleneck may be missing indexes or inefficient joins rather than EF Core itself.
Bulk Updates Don't Trigger Business Logic
ExecuteUpdateAsync() and ExecuteDeleteAsync() operate directly against the database and bypass entity tracking. If validation or domain events are required, update entities through the normal change tracker.
Unexpected Missing Data
Check whether a global query filter is excluding records. Use IgnoreQueryFilters() only when appropriate.
High Memory Usage
Review tracking behavior and verify that read-only queries consistently use AsNoTracking().
Conclusion
EF Core 10 provides powerful features for building high-performance data access layers, but their effectiveness depends on how they're applied. Using AsNoTracking() for read-only queries, global query filters for consistent data access, compiled queries for frequently executed operations, and bulk update APIs for large-scale modifications can significantly improve application efficiency. Rather than optimizing every query indiscriminately, profile your application, identify real bottlenecks, and apply these techniques where they provide measurable value. A balanced approach results in applications that are both scalable and maintainable in production.

Jasen FiciPosted Aug 6, 2026, 12:59 PM
We included this article in DotNetNews here: https://dotnetnews.co/archive/the-net-news-daily-issue-513/