Entity Framework Core Performance Optimization: A Practical Guide for .NET Developers
Entity Framework Core (EF Core) simplifies database development by allowing .NET developers to work with strongly typed C# objects and LINQ instead of writing SQL for every database operation.
However, the convenience of an ORM can sometimes hide expensive operations.
A LINQ query that looks simple in C# can result in:
- Unnecessary database round trips
- Large result sets
- Excessive change tracking
- N+1 queries
- Expensive joins
- Unnecessary columns being retrieved
- Poor pagination performance
- Excessive memory usage
The goal of performance optimization is not to avoid EF Core. It is to understand how EF Core translates and executes queries and then design those queries appropriately.
Good EF Core performance comes from minimizing unnecessary database work, data transfer, object materialization, and application-side processing.
This article walks through the most important EF Core performance considerations, from query execution and related-data loading to CRUD operations, DbContext, diagnostics, and practical optimization patterns.
Contents Snapshot
-
1. Understanding Entity Framework Core Performance
- How EF Core executes queries
- LINQ-to-SQL translation
- Where EF Core performance bottlenecks occur
-
2. Common EF Core Performance Problems
- Loading unnecessary data
- Excessive entity tracking
- N+1 queries
- Lazy loading
- Excessive
Include() - Premature query execution
-
3. Optimizing EF Core Queries
- Projection with
Select() - DTO-based queries
AsNoTracking()- Filtering and sorting
- Avoiding client-side processing
- Inspecting generated SQL
- Projection with
-
4. Managing Related Data
-
Include()andThenInclude() - Eager loading
- Explicit loading
- Lazy loading
-
AsSplitQuery()vs single queries
-
-
5. Pagination and Large Data Sets
-
Skip()andTake() - Keyset pagination
- Efficient querying of large tables
-
-
6. Insert, Update, and Delete Performance
SaveChangesAsync()- Batch operations
ExecuteUpdateAsync()ExecuteDeleteAsync()- Handling large data operations
-
7. DbContext Performance
- DbContext lifetime
- Change tracking
- Connection management
- Async database operations
- Context pooling
-
8. EF Core Performance Patterns and Anti-Patterns
- Common mistakes
- Recommended patterns
- When to use or avoid specific EF Core features
-
9. Measuring and Diagnosing EF Core Performance
- Logging
- Generated SQL
- Query timing
- Profiling
- Identifying slow queries
-
10. Practical EF Core Performance Checklist
- Query checklist
- Tracking checklist
- Loading checklist
- Write-operation checklist
- Production checklist
- 11. Key Takeaways
1. Understanding Entity Framework Core Performance
Before optimizing EF Core, it is important to understand what happens when a query is executed.
Consider this simple query:
var customers = await dbContext.Customers
.Where(x => x.IsActive)
.ToListAsync();
At a high level, the execution flow looks like this:
C# LINQ Query
|
v
EF Core
|
v
Expression Translation
|
v
Generated SQL
|
v
Database
|
v
Result Set
|
v
EF Core Materialization
|
v
C# Objects
Several stages can contribute to the overall execution time.
How EF Core Executes Queries
When an IQueryable is built, EF Core does not immediately execute the query.
For example:
var query = dbContext.Customers
.Where(x => x.IsActive)
.OrderBy(x => x.Name);
At this point, the query has been composed but not necessarily executed.
Execution occurs when a terminal operation is called:
var customers = await query.ToListAsync();
Other examples include:
await query.FirstAsync();
await query.SingleAsync();
await query.CountAsync();
await query.AnyAsync();
This distinction is important because adding filters and projections before query execution allows EF Core to translate more of the work into SQL.
LINQ-to-SQL Translation
Consider:
var customers = await dbContext.Customers
.Where(x => x.IsActive)
.Select(x => new
{
x.Id,
x.Name
})
.ToListAsync();
Conceptually, EF Core translates the LINQ expression into SQL similar to:
SELECT
Id,
Name
FROM Customers
WHERE IsActive = 1;
The exact SQL depends on the EF Core version, provider, model configuration, and query.
This means developers should understand both sides:
LINQ
|
v
EF Core Translation
|
v
SQL
|
v
Database Execution
A query that looks efficient in C# is not necessarily efficient after translation.
Where EF Core Performance Bottlenecks Occur
Potential bottlenecks include:
- Query translation
- Database execution
- Network transfer
- Entity materialization
- Change tracking
- Application-side processing
- Excessive database round trips
- Large result sets
Therefore:
Do not optimize EF Core based only on how the C# code looks. Measure what the application and database actually do.
2. Common EF Core Performance Problems
Loading Unnecessary Data
One of the most common problems is retrieving more data than the application needs.
Consider:
var customers = await dbContext.Customers
.Where(x => x.IsActive)
.ToListAsync();
Suppose the Customer entity contains:
- Id
- Name
- Phone
- Address
- ProfileImage
- Preferences
- Audit fields
If the API only needs:
Id
Name
Email
loading the entire entity is unnecessary.
This can increase:
- Database I/O
- Network traffic
- Memory usage
- Entity materialization
- Serialization cost
Projection is usually a better approach for read-only API scenarios.
var customers = await dbContext.Customers
.Where(x => x.IsActive)
.Select(x => new CustomerDto
{
Id = x.Id,
Name = x.Name,
Email = x.Email
})
.ToListAsync();
Only the required fields are requested by the query.
Excessive Entity Tracking
EF Core uses change tracking to detect modifications to entities.
This is useful when updating data:
var customer = await dbContext.Customers
.FirstAsync(x => x.Id == customerId);
customer.Name = "John";
await dbContext.SaveChangesAsync();
EF Core tracks the customer and can determine what changed.
For read-only scenarios, tracking may not be necessary.
var customers = await dbContext.Customers
.AsNoTracking()
.Where(x => x.IsActive)
.ToListAsync();
AsNoTracking() tells EF Core not to track the returned entities for changes.
This can reduce tracking overhead for appropriate read-heavy workloads.
However, it should not be added blindly everywhere.
Ask:
Does this query need to modify the returned entities?
If the answer is no, no-tracking may be appropriate.
The N+1 Query Problem
The N+1 query problem occurs when an application executes one query to retrieve a collection and then performs additional queries for each item.
Consider:
var orders = await dbContext.Orders
.ToListAsync();
foreach (var order in orders)
{
Console.WriteLine(order.Customer.Name);
}
Depending on the relationship-loading configuration, this can result in:
1 query -> Orders
N queries -> Customers
If there are 5,000 orders, the application could potentially perform thousands of additional database operations.
Even when individual queries are fast, the combined overhead can become significant.
Better Approach
Projection can retrieve the required related data as part of the query:
var orders = await dbContext.Orders
.Select(x => new OrderDto
{
Id = x.Id,
Amount = x.Amount,
CustomerName = x.Customer.Name
})
.ToListAsync();
The important principle is:
Minimize unnecessary database round trips.
Lazy Loading
Lazy loading retrieves related data when a navigation property is accessed.
For example:
var order = await dbContext.Orders
.FirstAsync(x => x.Id == orderId);
var customerName = order.Customer.Name;
With lazy loading enabled, accessing Customer can trigger another database query.
This can be convenient, but it can also hide database operations inside ordinary property access.
For example:
foreach (var order in orders)
{
Console.WriteLine(order.Customer.Name);
}
What looks like a simple loop may result in many database calls.
Practical Consideration
Lazy loading can be useful in some scenarios, but it should be used carefully in performance-sensitive applications.
For API workloads, explicit query shaping and projection often make database access easier to understand and control.
Excessive Include()
Include() is useful when related entities are genuinely required.
For example:
var orders = await dbContext.Orders
.Include(x => x.Customer)
.ToListAsync();
However, adding many relationships to a query can result in complex SQL and large result sets.
For example:
var orders = await dbContext.Orders
.Include(x => x.Customer)
.Include(x => x.Items)
.Include(x => x.Payments)
.Include(x => x.Shipments)
.ToListAsync();
This may result in a large query and repeated data across joined rows.
If the API only needs selected fields, projection is often clearer:
var orders = await dbContext.Orders
.Select(x => new OrderSummaryDto
{
Id = x.Id,
Amount = x.Amount,
CustomerName = x.Customer.Name,
ItemCount = x.Items.Count()
})
.ToListAsync();
Use Include() when you actually need entity graphs.
Use projection when you need a specific result shape.
Premature Query Execution
Consider:
var customers = await dbContext.Customers
.ToListAsync();
var activeCustomers = customers
.Where(x => x.IsActive)
.ToList();
The entire customer table is loaded into memory before filtering.
A better approach is:
var activeCustomers = await dbContext.Customers
.Where(x => x.IsActive)
.ToListAsync();
Now the filtering happens in the database.
A useful rule is:
Keep the query as
IQueryablewhile composing database operations, and execute it only when you actually need the results.
3. Optimizing EF Core Queries
Projection with Select()
Projection is one of the most useful EF Core optimization techniques.
Instead of retrieving complete entities:
var products = await dbContext.Products
.Where(x => x.IsActive)
.ToListAsync();
project only the required fields:
var products = await dbContext.Products
.Where(x => x.IsActive)
.Select(x => new ProductDto
{
Id = x.Id,
Name = x.Name,
Price = x.Price
})
.ToListAsync();
This reduces the amount of data transferred from the database.
It can also reduce application memory consumption and object materialization.
Why Projection Matters
Consider an entity with 30 columns.
If an API requires only 4 columns:
Entity
|
+-- 30 columns
|
v
Database
|
v
Application
Projection changes this to:
Entity
|
+-- 4 required columns
|
v
Database
|
v
Application
This becomes increasingly important as data volume grows.
DTO-Based Queries
Using DTOs also creates a clear boundary between persistence models and API contracts.
Example:
public sealed class CustomerDto
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public string Email { get; init; } = string.Empty;
}
Query:
var customers = await dbContext.Customers
.AsNoTracking()
.Where(x => x.IsActive)
.Select(x => new CustomerDto
{
Id = x.Id,
Name = x.Name,
Email = x.Email
})
.ToListAsync();
This approach has several advantages:
- Explicit data requirements
- Smaller result sets
- Reduced coupling
- Clear API contracts
- Less unnecessary entity materialization
AsNoTracking()
For read-only queries:
var products = await dbContext.Products
.AsNoTracking()
.Where(x => x.IsActive)
.ToListAsync();
Use tracking when you need to modify the entity:
var product = await dbContext.Products
.FirstAsync(x => x.Id == productId);
product.Price = 100;
await dbContext.SaveChangesAsync();
Use no-tracking when the result is simply being read:
GET API
|
v
Query database
|
v
Return response
The choice should be based on the query's behavior, not applied as a blanket rule.
Filtering and Sorting
Filtering should generally happen in the database rather than after materialization.
Avoid:
var customers = await dbContext.Customers
.ToListAsync();
var result = customers
.Where(x => x.Country == "India")
.OrderBy(x => x.Name)
.ToList();
Prefer:
var result = await dbContext.Customers
.Where(x => x.Country == "India")
.OrderBy(x => x.Name)
.ToListAsync();
This allows the database to perform the filtering and sorting.
For large datasets, database-side operations are generally preferable to loading unnecessary rows into application memory.
Avoiding Client-Side Processing
A common performance problem occurs when developers unintentionally move processing from the database into application memory.
For example:
var customers = await dbContext.Customers
.ToListAsync();
var result = customers
.Where(x => x.Name.StartsWith("A"))
.ToList();
Instead:
var result = await dbContext.Customers
.Where(x => x.Name.StartsWith("A"))
.ToListAsync();
The database can process the filtering before returning the result.
The general pattern is:
Avoid:
Database
|
v
Large result set
|
v
Application filtering
Prefer:
Database
|
+-- Filter
+-- Sort
+-- Project
|
v
Small result set
|
v
Application
Inspecting Generated SQL
Do not assume that a LINQ query generates the SQL you expect.
EF Core provides ToQueryString() for inspecting the SQL representation of a query.
var query = dbContext.Customers
.Where(x => x.IsActive)
.Select(x => new CustomerDto
{
Id = x.Id,
Name = x.Name
});
var sql = query.ToQueryString();
Console.WriteLine(sql);
This is useful when diagnosing:
- Unexpected joins
- Missing filters
- Excessive columns
- Complex queries
- Unexpected query shapes
For production diagnosis, application logging and database monitoring should also be used rather than relying only on ToQueryString().
4. Managing Related Data
Relationships are one of the areas where EF Core can generate unexpected database work.
Consider:
Order
|
+-- Customer
|
+-- OrderItems
|
+-- Payments
The correct loading strategy depends on what the application actually needs.
Include() and ThenInclude()
Include() loads related data.
var orders = await dbContext.Orders
.Include(x => x.Customer)
.ToListAsync();
For nested relationships:
var orders = await dbContext.Orders
.Include(x => x.Customer)
.ThenInclude(x => x.Address)
.ToListAsync();
Use this when the related entities are actually required.
Avoid adding Include() simply because the relationship exists.
Eager Loading
Eager loading retrieves related data as part of the query.
Example:
var orders = await dbContext.Orders
.Include(x => x.Customer)
.ToListAsync();
Advantages:
- Explicit
- Easy to understand
- Avoids some hidden database calls
Potential downside:
- Large entity graphs can produce complex queries and large result sets
For read-only APIs, projection can often provide a more precise result shape.
Explicit Loading
Explicit loading allows the application to deliberately load related data.
For example:
var order = await dbContext.Orders
.FirstAsync(x => x.Id == orderId);
await dbContext.Entry(order)
.Reference(x => x.Customer)
.LoadAsync();
This gives the application explicit control over when related data is loaded.
It can be useful when the related data is conditionally required.
However, repeated explicit loading inside loops can still create N+1 behavior.
Lazy Loading
Lazy loading loads related entities when they are accessed.
Although convenient, it can make database activity less visible in application code.
For example:
foreach (var order in orders)
{
Console.WriteLine(order.Customer.Name);
}
The loop does not visibly contain a database query, but accessing Customer can trigger one when lazy loading is enabled.
For performance-sensitive applications, make database access explicit whenever practical.
AsSplitQuery() vs Single Queries
Consider a query with multiple collection relationships:
var orders = await dbContext.Orders
.Include(x => x.Items)
.Include(x => x.Payments)
.ToListAsync();
A single-query approach can produce large joins.
EF Core supports split queries:
var orders = await dbContext.Orders
.Include(x => x.Items)
.Include(x => x.Payments)
.AsSplitQuery()
.ToListAsync();
Conceptually:
Single Query
Orders
|
+-- Items
|
+-- Payments
versus:
Split Query
Query 1 -> Orders
Query 2 -> Items
Query 3 -> Payments
Split queries can reduce some join-related duplication, but they also mean multiple database round trips.
Therefore:
Choose between single and split queries based on the actual query shape and measured performance.
5. Pagination and Large Data Sets
Returning thousands or millions of rows from an API is rarely appropriate.
Pagination limits the amount of data processed and returned.
Skip() and Take()
A common approach is:
var customers = await dbContext.Customers
.OrderBy(x => x.Id)
.Skip(page * pageSize)
.Take(pageSize)
.ToListAsync();
For example:
Page size = 50
Page = 2
The query retrieves the next 50 records after the previous 100 records.
This approach is simple and useful for many applications.
However, very large offsets can become increasingly expensive because the database may need to process rows that are skipped.
Keyset Pagination
For large datasets, keyset pagination can be an alternative.
Suppose the last item from the previous page has:
Id = 50000
The next query can be:
var customers = await dbContext.Customers
.Where(x => x.Id > lastCustomerId)
.OrderBy(x => x.Id)
.Take(50)
.ToListAsync();
Conceptually:
Previous page
|
v
Last Id = 50000
|
v
WHERE Id > 50000
|
v
Take 50
This approach is especially useful for large, sequential datasets.
When to Consider Keyset Pagination
Keyset pagination is useful when:
- The dataset is large
- Users navigate sequentially
- A stable ordering key exists
- Very deep pages are expected
Offset pagination can still be appropriate when users need direct access to arbitrary pages.
6. Insert, Update, and Delete Performance
EF Core performance is not limited to queries.
Write operations can also become expensive when large numbers of entities are involved.
SaveChangesAsync()
Avoid calling SaveChangesAsync() inside every iteration.
Inefficient
foreach (var customer in customers)
{
customer.IsActive = false;
await dbContext.SaveChangesAsync();
}
This can create many database round trips.
Better
foreach (var customer in customers)
{
customer.IsActive = false;
}
await dbContext.SaveChangesAsync();
This reduces the number of explicit save operations.
However, for very large datasets, loading and tracking every entity can still be expensive.
That is where set-based operations become useful.
Batch Operations
When processing large numbers of entities, consider the size of the batch.
For example:
1,000,000 records
|
v
Process in manageable batches
|
+-- Batch 1
+-- Batch 2
+-- Batch 3
+-- ...
Batching can help control:
- Memory consumption
- Transaction size
- Change tracker size
- Execution time
- Database pressure
The appropriate batch size depends on the workload and should be validated through testing.
ExecuteUpdateAsync()
For operations that can be expressed as a set-based update, ExecuteUpdateAsync() can avoid loading entities into memory.
For example:
await dbContext.Customers
.Where(x => !x.IsActive)
.ExecuteUpdateAsync(setters =>
setters.SetProperty(
x => x.IsArchived,
true));
Conceptually, the operation becomes:
Application
|
v
UPDATE statement
|
v
Database
rather than:
Database
|
v
Load entities
|
v
Application
|
v
Track entities
|
v
Modify entities
|
v
Save changes
For large set-based updates, this can substantially reduce application-side work.
ExecuteDeleteAsync()
Similarly, records can be deleted directly without loading every entity.
await dbContext.Customers
.Where(x => x.IsInactive)
.ExecuteDeleteAsync();
This is useful when the business operation is simply:
Delete all records matching condition
rather than:
Load each entity
|
v
Run application logic
|
v
Delete entities
Before using set-based operations, consider whether entity-level business rules, events, auditing, or other application logic need to run.
Handling Large Data Operations
For very large workloads, consider:
- Batching
- Set-based operations
- Appropriate transaction boundaries
- Memory consumption
- Database throughput
- Error recovery
- Idempotency
- Retry behavior
The right strategy depends on whether the operation is:
- User-driven
- Scheduled
- Background processing
- Bulk migration
- Data correction
- Real-time processing
7. DbContext Performance
DbContext is central to EF Core applications.
Understanding its lifecycle and behavior is important for performance and correctness.
DbContext Lifetime
In ASP.NET Core applications, a common configuration is:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));
This typically creates a scoped DbContext.
The conceptual request lifecycle is:
HTTP Request
|
v
DbContext
|
+-- Query
+-- Query
+-- Update
|
v
SaveChanges
|
v
Request Complete
|
v
DbContext Disposed
Avoid using a single DbContext instance across unrelated concurrent requests.
DbContext is not designed for concurrent operations on the same instance.
Change Tracking
The change tracker maintains information about entities being tracked.
For example:
var customer = await dbContext.Customers
.FirstAsync(x => x.Id == customerId);
customer.Name = "Updated Name";
await dbContext.SaveChangesAsync();
EF Core can determine that Name changed.
However, tracking thousands of entities can increase memory and processing overhead.
For read-heavy workloads, no-tracking queries can reduce this overhead:
var customers = await dbContext.Customers
.AsNoTracking()
.ToListAsync();
The right approach depends on the workload.
Connection Management
EF Core relies on the underlying database provider for connection management and pooling.
A simplified flow is:
Application
|
v
EF Core
|
v
Connection Pool
|
v
Database
Potential problems include:
- Connection pool exhaustion
- Long-running database operations
- Excessive concurrency
- Connections held longer than necessary
If an application experiences database timeouts, investigate whether the problem is:
Connection acquisition
|
or
v
Query execution
|
or
v
Database resource contention
Do not assume all database timeout problems have the same root cause.
Async Database Operations
Use asynchronous database APIs in ASP.NET Core applications:
var customers = await dbContext.Customers
.ToListAsync();
Similarly:
await dbContext.SaveChangesAsync();
Asynchronous I/O allows the application to avoid blocking request threads while waiting for database operations.
The benefit is particularly relevant for applications handling many concurrent requests.
Context Pooling
For some workloads, EF Core supports context pooling.
For example:
builder.Services.AddDbContextPool<AppDbContext>(options =>
options.UseSqlServer(connectionString));
Pooling allows EF Core to reuse DbContext instances rather than creating a new instance each time.
However, pooled contexts require careful consideration of state that might be stored on the context or related services.
Context pooling is an optimization that should be measured rather than enabled simply because it exists.
8. EF Core Performance Patterns and Anti-Patterns
Understanding what not to do is just as important as understanding the recommended patterns.
Common Mistakes
Loading Everything
var customers = await dbContext.Customers
.ToListAsync();
followed by application-side filtering.
Prefer database-side filtering:
var customers = await dbContext.Customers
.Where(x => x.IsActive)
.ToListAsync();
Returning Entities Directly From APIs
Returning EF entities directly can expose persistence models to API consumers.
Prefer DTOs:
var customers = await dbContext.Customers
.Select(x => new CustomerDto
{
Id = x.Id,
Name = x.Name
})
.ToListAsync();
Calling SaveChangesAsync() Repeatedly
Avoid:
foreach (var item in items)
{
dbContext.Update(item);
await dbContext.SaveChangesAsync();
}
Prefer a suitable batching or set-based strategy.
Using Lazy Loading Without Understanding Its Cost
Lazy loading can make database calls invisible in application code.
Be particularly careful with loops and large collections.
Adding Include() Everywhere
Include() is not a replacement for query design.
Ask:
Do I really need the complete related entity?
If the answer is no, projection may be more appropriate.
Recommended Patterns
For read-heavy API endpoints:
var result = await dbContext.Customers
.AsNoTracking()
.Where(x => x.IsActive)
.Select(x => new CustomerDto
{
Id = x.Id,
Name = x.Name,
Email = x.Email
})
.ToListAsync();
This combines several useful principles:
- Database-side filtering
- Projection
- Reduced tracking
- Explicit result shape
For large updates:
await dbContext.Customers
.Where(x => !x.IsActive)
.ExecuteUpdateAsync(setters =>
setters.SetProperty(
x => x.IsArchived,
true));
For large result sets:
var result = await dbContext.Customers
.Where(x => x.Id > lastCustomerId)
.OrderBy(x => x.Id)
.Take(50)
.ToListAsync();
When to Use or Avoid Specific EF Core Features
| Feature | Useful When | Be Careful When |
|---|---|---|
AsNoTracking() |
Read-only queries | Entities need to be updated through the context |
Include() |
Related entities are required | Many large collection relationships are included |
AsSplitQuery() |
Large relationship graphs | Additional database round trips matter |
| Lazy loading | Convenience is important | Query count must be predictable |
| Projection | APIs need specific fields | Complex result shapes require careful testing |
ExecuteUpdateAsync() |
Large set-based updates | Entity-level business logic must execute |
ExecuteDeleteAsync() |
Large set-based deletes | Per-entity processing is required |
| Context pooling | High-throughput workloads | Context-specific mutable state is used |
The important point is that there is no single EF Core feature that makes every application faster.
9. Measuring and Diagnosing EF Core Performance
Performance optimization should start with measurement.
Do not optimize based solely on assumptions.
Logging
EF Core can provide database-related logging that helps identify queries and execution behavior.
For example:
builder.Services.AddDbContext<AppDbContext>(options =>
{
options
.UseSqlServer(connectionString)
.EnableDetailedErrors();
});
Be careful when enabling detailed logging in production because sensitive information can potentially appear in logs depending on the configuration.
Generated SQL
For a specific query:
var query = dbContext.Customers
.Where(x => x.IsActive)
.Select(x => new CustomerDto
{
Id = x.Id,
Name = x.Name
});
var sql = query.ToQueryString();
Inspecting generated SQL can reveal unexpected behavior.
For example:
Expected:
SELECT Id, Name
FROM Customers
WHERE IsActive = 1
Unexpected:
SELECT many columns
FROM Customers
JOIN ...
JOIN ...
JOIN ...
This can lead to further investigation.
Query Timing
Measure actual execution time rather than assuming a query is slow.
For example:
var stopwatch = Stopwatch.StartNew();
var customers = await dbContext.Customers
.AsNoTracking()
.Where(x => x.IsActive)
.ToListAsync();
stopwatch.Stop();
Console.WriteLine(
$"Query completed in {stopwatch.ElapsedMilliseconds} ms");
For production applications, use structured logging and telemetry instead of ad-hoc console output.
Profiling
For a production-grade application, consider observing:
HTTP Request
|
v
Application Code
|
v
EF Core
|
v
Database Dependency
Useful measurements include:
- API latency
- Database query duration
- Number of database calls
- Result-set size
- Error rate
- Request throughput
- Memory usage
- Connection utilization
The objective is to identify where the time is actually being spent.
Identifying Slow Queries
A useful troubleshooting process is:
Slow API
|
v
Measure API latency
|
v
Identify database dependency
|
v
Identify slow query
|
v
Inspect generated SQL
|
v
Analyze execution
|
v
Optimize EF Core query
|
v
Measure again
If the database query itself is efficient but the API is still slow, investigate other parts of the request pipeline.
10. Practical EF Core Performance Checklist
Query Checklist
- [ ] Are only required columns being selected?
- [ ] Is filtering performed in the database?
- [ ] Is projection used where appropriate?
- [ ] Are queries executed only when needed?
- [ ] Are N+1 queries eliminated?
- [ ] Is generated SQL understood?
- [ ] Are large result sets paginated?
Tracking Checklist
- [ ] Does the query actually require change tracking?
- [ ] Is
AsNoTracking()used for appropriate read-only queries? - [ ] Is the number of tracked entities reasonable?
- [ ] Are long-lived
DbContextinstances avoided?
Loading Checklist
- [ ] Is
Include()used only when required? - [ ] Are large relationship graphs avoided?
- [ ] Is lazy loading being used intentionally?
- [ ] Could projection replace entity loading?
- [ ] Should
AsSplitQuery()be considered for the query shape?
Write-Operation Checklist
- [ ] Is
SaveChangesAsync()being called unnecessarily inside loops? - [ ] Can the operation be performed in batches?
- [ ] Can
ExecuteUpdateAsync()be used? - [ ] Can
ExecuteDeleteAsync()be used? - [ ] Are transaction boundaries appropriate?
- [ ] Is application-level business logic required for each entity?
Production Checklist
- [ ] Is query performance measured?
- [ ] Are database calls observable?
- [ ] Are slow queries identifiable?
- [ ] Are application logs configured appropriately?
- [ ] Are connection issues monitored?
- [ ] Are realistic datasets used for performance testing?
- [ ] Are performance changes validated before and after deployment?
11. Key Takeaways
Entity Framework Core provides powerful abstractions for working with relational databases, but those abstractions do not remove the need to understand database access patterns.
The most important principles are:
- Understand how LINQ becomes SQL.
- Retrieve only the data the application actually needs.
- Use projection for precise read models.
- Use
AsNoTracking()for appropriate read-only workloads. - Avoid N+1 queries.
- Use
Include()carefully. - Understand the trade-offs of lazy loading.
- Keep filtering and sorting in the database where appropriate.
- Avoid premature query execution.
- Use appropriate pagination for large datasets.
- Avoid unnecessary
SaveChangesAsync()calls. - Consider set-based operations for large updates and deletes.
- Manage
DbContextlifetime correctly. - Use asynchronous database operations.
- Measure performance instead of relying on assumptions.
A useful mental model is:
EF Core Performance
|
+--------------+--------------+
| | |
v v v
Query Tracking Loading
| | |
v v v
Projection NoTracking Include
Filtering Context Lazy Load
Pagination Lifetime Split Query
| | |
+--------------+--------------+
|
v
Database Work
|
v
Measure & Tune
Conclusion
EF Core performance optimization is not about applying every available optimization.
It is about understanding the workload and choosing the simplest approach that avoids unnecessary work.
When an EF Core query becomes slow, start with a few fundamental questions:
What data do I actually need?
How many database calls am I making?
Is EF Core tracking entities unnecessarily?
What SQL is actually being generated?
Can the operation be performed efficiently at the database level?
Once these questions become part of the development and code-review process, many EF Core performance issues can be identified before they become production problems.
The best optimization is often not a complicated EF Core technique. It is simply doing less unnecessary work.
Top comments (0)