DEV Community

Cover image for Entity Framework Core Performance Optimization: A Practical Guide for .NET Developers
Chethan Ramaswamy
Chethan Ramaswamy

Posted on

Entity Framework Core Performance Optimization: A Practical Guide for .NET Developers

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

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Other examples include:

await query.FirstAsync();
await query.SingleAsync();
await query.CountAsync();
await query.AnyAsync();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Conceptually, EF Core translates the LINQ expression into SQL similar to:

SELECT
    Id,
    Name
FROM Customers
WHERE IsActive = 1;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Suppose the Customer entity contains:

  • Id
  • Name
  • Email
  • Phone
  • Address
  • ProfileImage
  • Preferences
  • Audit fields

If the API only needs:

Id
Name
Email
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Depending on the relationship-loading configuration, this can result in:

1 query  -> Orders

N queries -> Customers
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

The entire customer table is loaded into memory before filtering.

A better approach is:

var activeCustomers = await dbContext.Customers
    .Where(x => x.IsActive)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Now the filtering happens in the database.

A useful rule is:

Keep the query as IQueryable while 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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Projection changes this to:

Entity
  |
  +-- 4 required columns
  |
  v
Database
  |
  v
Application
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Use no-tracking when the result is simply being read:

GET API
  |
  v
Query database
  |
  v
Return response
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Prefer:

var result = await dbContext.Customers
    .Where(x => x.Country == "India")
    .OrderBy(x => x.Name)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Instead:

var result = await dbContext.Customers
    .Where(x => x.Name.StartsWith("A"))
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

For nested relationships:

var orders = await dbContext.Orders
    .Include(x => x.Customer)
        .ThenInclude(x => x.Address)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Conceptually:

Single Query

Orders
  |
  +-- Items
  |
  +-- Payments
Enter fullscreen mode Exit fullscreen mode

versus:

Split Query

Query 1 -> Orders
Query 2 -> Items
Query 3 -> Payments
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

For example:

Page size = 50
Page      = 2
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The next query can be:

var customers = await dbContext.Customers
    .Where(x => x.Id > lastCustomerId)
    .OrderBy(x => x.Id)
    .Take(50)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Conceptually:

Previous page
     |
     v
Last Id = 50000
     |
     v
WHERE Id > 50000
     |
     v
Take 50
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

This can create many database round trips.

Better

foreach (var customer in customers)
{
    customer.IsActive = false;
}

await dbContext.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

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
      +-- ...
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

Conceptually, the operation becomes:

Application
     |
     v
UPDATE statement
     |
     v
Database
Enter fullscreen mode Exit fullscreen mode

rather than:

Database
     |
     v
Load entities
     |
     v
Application
     |
     v
Track entities
     |
     v
Modify entities
     |
     v
Save changes
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

This is useful when the business operation is simply:

Delete all records matching condition
Enter fullscreen mode Exit fullscreen mode

rather than:

Load each entity
  |
  v
Run application logic
  |
  v
Delete entities
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Similarly:

await dbContext.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

followed by application-side filtering.

Prefer database-side filtering:

var customers = await dbContext.Customers
    .Where(x => x.IsActive)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Calling SaveChangesAsync() Repeatedly

Avoid:

foreach (var item in items)
{
    dbContext.Update(item);
    await dbContext.SaveChangesAsync();
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

For large result sets:

var result = await dbContext.Customers
    .Where(x => x.Id > lastCustomerId)
    .OrderBy(x => x.Id)
    .Take(50)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 DbContext instances 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:

  1. Understand how LINQ becomes SQL.
  2. Retrieve only the data the application actually needs.
  3. Use projection for precise read models.
  4. Use AsNoTracking() for appropriate read-only workloads.
  5. Avoid N+1 queries.
  6. Use Include() carefully.
  7. Understand the trade-offs of lazy loading.
  8. Keep filtering and sorting in the database where appropriate.
  9. Avoid premature query execution.
  10. Use appropriate pagination for large datasets.
  11. Avoid unnecessary SaveChangesAsync() calls.
  12. Consider set-based operations for large updates and deletes.
  13. Manage DbContext lifetime correctly.
  14. Use asynchronous database operations.
  15. 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
Enter fullscreen mode Exit fullscreen mode

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.


Tags

dotnet #csharp #efcore #entityframework #aspnetcore #performance #softwarearchitecture #programming

Top comments (0)