EF Core Query Performance: AsNoTracking, Loading & N+1

Most EF Core performance problems don't announce themselves. You write clean LINQ, the app works fine locally against a small seed database, and then traffic or data volume grows and suddenly a product list endpoint takes three seconds. The query itself didn't change — your assumptions about what EF Core was doing under the hood did.

EF Core query performance best practices showing AsNoTracking and loading strategy code on a clean dashboard

This guide targets the specific category of problems that bite intermediate developers the hardest: change-tracking overhead on read-only queries, the wrong loading strategy for the wrong situation, and the N+1 query problem silently multiplying database round-trips. We'll look at each one practically, with real code and the generated SQL to make the behavior concrete.

The examples throughout target EF Core 10 on .NET 10 with C# 14, though the concepts and APIs apply to any currently supported version.

Prerequisites

You'll get the most out of this article if you've already built at least one CRUD application with EF Core and have a working DbContext with navigation properties configured. Specifically, you should be comfortable with:

  • Defining entity classes and configuring relationships (HasMany, HasOne, etc.)
  • Writing basic LINQ queries against a DbContext
  • Running dotnet ef migrations to apply schema changes

No specific IDE is required. The code samples assume SQL Server as the database provider, though the patterns apply equally to PostgreSQL or SQLite.

Understanding Change Tracking Overhead

Every time EF Core executes a query by default, it runs the results through its change tracker — an internal data structure that takes a snapshot of every entity returned so it can detect modifications when you later call SaveChanges(). That snapshot costs CPU time to create and memory to hold. For a write operation, that cost is completely justified. For a read-only operation — a GET endpoint returning a product catalog, a report page, a search result — it's pure waste.

The AsNoTracking() extension method tells EF Core to skip that snapshot entirely. The entities are still materialized and returned, but the change tracker never touches them. If you modify the entity instances after the query runs, EF Core won't detect those changes, and a later call to SaveChanges() will silently ignore them — which for a pure read is exactly the behavior you want.

The Performance Impact Is Real

Independent benchmarks on typical entity shapes (10–20 properties, a few navigation properties) commonly show tracked queries allocating roughly twice the memory of the equivalent no-tracking query, though the exact multiplier varies with entity complexity, hardware, and EF Core version. The pattern itself is consistent across these benchmarks: the more entities returned, the larger the relative gain from skipping the change tracker.

Here is the simplest form of the pattern:

csharp
// ❌ Default behavior — tracked, unnecessary for a read-only list endpoint
var products = await context.Products
    .Where(p => p.IsActive)
    .ToListAsync();

// ✅ No-tracking — correct for any query where you won't call SaveChanges()
var products = await context.Products
    .AsNoTracking()
    .Where(p => p.IsActive)
    .ToListAsync();

The generated SQL is identical in both cases. The difference is entirely in what EF Core does after the data comes back from the database — and for large result sets, that post-processing is the bottleneck.

AsNoTrackingWithIdentityResolution: The Middle Ground

Plain AsNoTracking() has one side effect worth knowing about: if the same entity appears more than once in the result set (say, the same Customer referenced by multiple Order rows), EF Core creates a separate object instance for each occurrence rather than reusing one. That's fine for simple queries, but it can produce confusing, duplicated object graphs in more complex ones.

AsNoTrackingWithIdentityResolution() solves that: it skips the change-tracking snapshot but maintains an identity map, so duplicate entities in the result are collapsed into the same CLR object reference. It sits between full tracking and plain AsNoTracking() in terms of both correctness and performance, and maintaining that identity map carries its own CPU and memory cost. Its behavior isn't identical across every query shape, so profile it in your specific scenario rather than reaching for it as a default.

Setting No-Tracking Globally

If you have a DbContext that is used exclusively for reads — common in CQRS architectures with a dedicated read model — you can configure no-tracking at the model level using HasQueryTrackingBehavior:

csharp
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder
        .UseSqlServer(connectionString)
        .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}

This sets the default for every query executed against that context. Individual queries that do need tracking can opt back in with .AsTracking(). The global approach is cleaner than remembering to add AsNoTracking() to every query, but it should only be applied to contexts where writes genuinely never happen — accidentally omitting .AsTracking() on a query before a SaveChanges() call is a subtle bug that won't produce an error, it just silently does nothing.

Choosing the Right Loading Strategy

EF Core offers three ways to load navigation properties (related entities accessed via reference or collection properties on an entity). Choosing the wrong one is one of the most common sources of performance problems.

Eager Loading with Include and ThenInclude

Eager loading tells EF Core to load related data in the same database round-trip as the root entity. You use Include() for direct navigation properties and ThenInclude() for properties one level deeper. EF Core translates both into SQL JOINs in the generated query.

csharp
var orders = await context.Orders
    .AsNoTracking()
    .Include(o => o.Customer)
    .ThenInclude(c => c.Address)
    .Include(o => o.Items)
    .Where(o => o.CreatedAt >= DateTime.UtcNow.AddDays(-30))
    .ToListAsync();

This is the right default for most API endpoints where you know upfront what related data you need. One round-trip, predictable query shape, no surprises.

The Cartesian Explosion Problem

Eager loading has one painful failure mode: when you Include() more than one collection navigation property on the same entity, EF Core generates a single SQL query with multiple JOINs. The result set is the cross product of those collections — a problem called cartesian explosion.

Imagine an Order with 10 Items and 5 Tags. A single-query eager load returns 10 × 5 = 50 rows, with every item column repeated 5 times and every tag column repeated 10 times. Add another collection and it multiplies again. With realistic data volumes, this can easily produce result sets thousands of times larger than the actual data, and the network cost of transferring all those duplicate rows back to the application can push a query from milliseconds to seconds.

AsSplitQuery() fixes this by telling EF Core to issue a separate SQL query per included collection rather than one giant JOIN:

csharp
var orders = await context.Orders
    .AsNoTracking()
    .Include(o => o.Customer)
    .Include(o => o.Items)
    .Include(o => o.Tags)
    .AsSplitQuery()
    .ToListAsync();

Instead of one large query, EF Core executes multiple smaller queries — one for Orders, one for each included collection — which avoids the row multiplication entirely.

That fix comes with a trade-off worth taking seriously: split queries mean multiple round-trips to the database, and by default there's no guarantee the separate queries see a transactionally consistent snapshot of the data. A concurrent update between round-trips can produce mismatched results. For read-only scenarios this is rarely a practical problem, but if you're computing cross-collection aggregates or need a guaranteed consistent view, wrap the split query in an explicit transaction — which mitigates the inconsistency risk but moves the performance cost elsewhere.

A practical rule of thumb: reach for AsSplitQuery() when you're including more than one one-to-many collection in the same query. For a query with a single Include(), it just adds an unnecessary round-trip.

You can also enable split queries globally:

csharp
optionsBuilder.UseSqlServer(
    connectionString,
    o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));

Lazy Loading — and Why to Avoid It in Production

Lazy loading works by generating a proxy class for each entity at runtime. When you access a navigation property on that proxy outside of the original query, EF Core automatically fires a new SQL query to fetch the related data. It feels magical in a demo, but the behavior that makes it convenient is exactly what makes it dangerous.

The problem is that lazy loading moves the database round-trips into a loop. If you load a list of 50 orders and then access order.Customer for each one in a foreach, EF Core fires 50 separate queries — one per order. This is the N+1 query problem: one query to fetch the N items, then N additional queries to fetch each related entity. At scale, it's devastating.

Lazy loading is disabled by default in EF Core for this reason. To enable it, you'd install the Microsoft.EntityFrameworkCore.Proxies package and call .UseLazyLoadingProxies() in your options. My strong recommendation: don't enable it globally in a server application. The risk of accidentally triggering N+1 queries, especially inside serialization paths, far outweighs the convenience.

Explicit Loading

Explicit loading is a controlled alternative: you load the root entity first, then manually trigger additional loads for specific navigation properties using context.Entry(entity).Collection(...).LoadAsync() or context.Entry(entity).Reference(...).LoadAsync(). It's essentially lazy loading that you call deliberately.

csharp
var order = await context.Orders.FirstOrDefaultAsync(o => o.Id == orderId);

if (order is not null)
{
    await context.Entry(order)
        .Collection(o => o.Items)
        .LoadAsync();
}

This is useful when you genuinely don't know at query time whether you'll need the related data — for example, in a service method that conditionally loads extra detail based on a flag. It's also a useful pattern when the related collection is very large and you want to apply filtering to it before loading:

csharp
await context.Entry(order)
    .Collection(o => o.Items)
    .Query()
    .Where(i => i.IsActive)
    .LoadAsync();

Loading Strategy Decision Guide

ScenarioRecommended strategy
You know upfront what related data you needEager loading (Include)
Multiple collection navigations on the same entityEager loading + AsSplitQuery()
Related data is only needed under certain conditionsExplicit loading
Performance-sensitive read with no need for navigation propertiesProjection to DTO
Lazy loading globally enabledAvoid — high N+1 risk

Detecting and Eliminating N+1 Queries

The N+1 problem often isn't obvious in code review because it doesn't look like a loop over a database call. It looks like normal property access. The most reliable way to catch it is to inspect the SQL that EF Core generates.

Enable EF Core logging in development by configuring it in Program.cs:

csharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default"))
           .LogTo(Console.WriteLine, LogLevel.Information)
           .EnableSensitiveDataLogging()); // Development only

Watch the output for repeated queries against the same table with varying primary key parameters. That pattern — the same SELECT hitting the same table dozens of times in one request — is the N+1 signature.

For a more structured view in an ASP.NET Core application, MiniProfiler integrates with EF Core and renders a per-request query timeline in the browser. It makes N+1 problems obvious even when the code path is buried in service layers.

DTO Projection: Often Better Than AsNoTracking Alone

AsNoTracking() eliminates change-tracking overhead, but it still fetches every column in the entity. If your Product entity has 20 columns and your list endpoint only needs 4 of them, you're shipping 16 unnecessary columns across the network on every row.

Projecting to a DTO (Data Transfer Object) using Select() solves this at the SQL level — EF Core translates the projection into a SELECT statement that only asks for the columns you actually use:

csharp
public record ProductSummaryDto(int Id, string Name, decimal Price);

var summaries = await context.Products
    .Where(p => p.IsActive)
    .Select(p => new ProductSummaryDto(p.Id, p.Name, p.Price))
    .ToListAsync();

The generated SQL for this query contains only Id, Name, and Price in the SELECT clause — not the full entity. There's a secondary benefit too: when the result set contains only scalars, anonymous types, or DTOs rather than entity type instances, EF Core doesn't track it, so projection implicitly gives you no-tracking behavior without having to call AsNoTracking() separately, on top of the narrower query it already produces.

If your projection needs outer-join semantics — for example, including a product's latest review even when it has none — EF Core 10 also introduced first-class LeftJoin() and RightJoin() LINQ operators. They translate directly to SQL LEFT JOIN/RIGHT JOIN and are worth reaching for instead of the older GroupJoin() + SelectMany() + DefaultIfEmpty() pattern, which is harder to read and easier to get wrong.

One common pitfall: if you use AutoMapper's ProjectTo<TDto>() extension, the projection is also translated to SQL — but the translation quality depends on the complexity of your mapping profile. Complex AutoMapper configurations can generate suboptimal SQL, or worse, fall back to client-side evaluation where EF Core fetches the full entity and applies the mapping in memory. Manual Select() projections are more explicit and easier to audit for what actually hits the database.

Async Queries and Pagination

Two more practices that belong in any discussion of Entity Framework Core query performance best practices, even briefly.

Always use the async query methods — ToListAsync(), FirstOrDefaultAsync(), AnyAsync(), CountAsync() — rather than their synchronous counterparts. This keeps the ASP.NET Core thread pool free to handle other requests while the database query is in flight. It doesn't make a single query faster, but under load it prevents thread-pool starvation, which is what causes a whole server to slow down rather than just one request.

Pagination using Skip() and Take() is equally important for any endpoint that returns a collection. Without it, a query against a table that grows to 100,000 rows fetches all of them into memory on every request. Combined with AsNoTracking() and projection, pagination keeps memory allocations flat regardless of table growth:

csharp
var page = await context.Orders
    .AsNoTracking()
    .Where(o => o.CustomerId == customerId)
    .OrderByDescending(o => o.CreatedAt)
    .Skip((pageNumber - 1) * pageSize)
    .Take(pageSize)
    .Select(o => new OrderSummaryDto(o.Id, o.CreatedAt, o.TotalAmount))
    .ToListAsync();

Always pair Skip/Take with a deterministic OrderBy. Without an explicit ordering, the database is free to return rows in any order and your pages will overlap or skip rows unpredictably.

Compiled Queries for Hot Paths

When the same query runs thousands of times per second — think authentication checks, session lookups, or product-detail pages on a high-traffic site — EF Core's LINQ-to-SQL translation is a small but repeated overhead. Compiled queries, defined with EF.CompileAsyncQuery, perform that translation once at startup and reuse the result on every call. The EF type lives in the Microsoft.EntityFrameworkCore namespace, so make sure that using directive is present before copying the snippet below:

csharp
private static readonly Func<AppDbContext, int, Task<ProductSummaryDto?>> GetProductById =
    EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
        ctx.Products
            .AsNoTracking()
            .Where(p => p.Id == id && p.IsActive)
            .Select(p => new ProductSummaryDto(p.Id, p.Name, p.Price))
            .FirstOrDefault());

// Usage inside a service method:
var product = await GetProductById(context, productId);

The compiled query is defined as a static field so it is created only once for the lifetime of the application. Reserve this pattern for genuinely hot paths — the added verbosity isn't worth it for queries that run once per user session.

Common Errors and Troubleshooting

Calling `SaveChanges()` on an entity loaded with `AsNoTracking()`.

EF Core won't throw immediately, but the changes will be silently ignored because the entity is not in the change tracker. If you load an entity for display and then unexpectedly need to update it, either re-query it without AsNoTracking() or attach it to the context manually with context.Attach(entity) followed by marking the modified state.

Projection that accidentally forces client-side evaluation.

If your Select() or GroupBy() calls a method EF Core can't translate to SQL, the provider may throw an InvalidOperationException at runtime or, in some configurations, silently fetch the full entity set and evaluate the expression in memory. Check the generated SQL using logging when you introduce a new projection or grouping, and keep the logic to simple property mapping, string concatenation, and operations EF Core is known to translate cleanly.

Enabling `AsSplitQuery()` globally when most queries only include one collection.

Split queries add a database round-trip per collection. For queries with a single Include(), a split query is strictly worse than a single query. Enable it per-query where you've confirmed cartesian explosion is a problem, rather than as a blanket default.

N+1 hiding inside a service or repository layer.

If a repository method returns a List<Order> and the calling service then accesses order.Items on each element, lazy loading (if enabled) or an unintentional re-query creates N+1 problems that aren't visible at the query definition site. The fix is to eagerly load what the caller needs, or shape the query in the repository to return a projection that already includes the required data.

Conclusion

Entity Framework Core query performance best practices aren't a checklist to apply uniformly — they're a set of decisions that depend on what a query is actually doing. Use AsNoTracking() or DTO projection on every read-only query; choose eager loading with Include() by default, switching to AsSplitQuery() when multiple collection navigations would otherwise explode the result set; and treat lazy loading as something to actively opt out of rather than a convenience to enable. Together, these changes can cut memory usage substantially and reduce query time significantly on realistic data volumes. The next step worth taking is enabling EF Core SQL logging in your development environment and spending an hour reviewing what your existing endpoints actually generate — most teams find at least one N+1 problem they didn't know about.

Codingvila provides articles and blogs on web and software development for beginners as well as free Academic projects for final year students in Asp.Net, MVC, C#, Vb.Net, SQL Server, Angular Js, Android, PHP, Java, Python, Desktop Software Application and etc.

If you have any questions, contact us on info.codingvila@gmail.com