Skip to main content

Command Palette

Search for a command to run...

When AsNoTracking Makes EF Core Slower

Updated
13 min readView as Markdown
When AsNoTracking Makes EF Core Slower
P
Senior Software Engineer specialising in cloud architecture, distributed systems, and modern .NET development, with over two decades of experience designing and delivering enterprise platforms in financial, insurance, and high-scale commercial environments. My focus is on building systems that are reliable, scalable, and maintainable over the long term. I’ve led modernisation initiatives moving legacy platforms to cloud-native Azure architectures, designed high-throughput streaming solutions to eliminate performance bottlenecks, and implemented secure microservices environments using container-based deployment models and event-driven integration patterns. From an architecture perspective, I have strong practical experience applying approaches such as Vertical Slice Architecture, Domain-Driven Design, Clean Architecture, and Hexagonal Architecture. I’m particularly interested in modular system design that balances delivery speed with long-term sustainability, and I enjoy solving complex problems involving distributed workflows, performance optimisation, and system reliability. I enjoy mentoring engineers, contributing to architectural decisions, and helping teams simplify complex systems into clear, maintainable designs. I’m always open to connecting with other engineers, architects, and technology leaders working on modern cloud and distributed system challenges.

There is a piece of EF Core advice that appears in almost every performance discussion:

If the query is read-only, use AsNoTracking().

Its usually good advice. Tracking entities requires EF Core to maintain information about every entity it materialises. It keeps references to those entities, records original values and performs identity resolution so that repeated references to the same database row resolve to the same CLR object. Remove tracking and some of that work disappears.

var orders = await dbContext.Orders
    .AsNoTracking()
    .Where(x => x.CustomerId == customerId)
    .ToListAsync(stopToken);

For many read only queries, this is exactly what you want. But turning off tracking changes more than whether SaveChangesAsync() notices modifications. It also changes how EF Core constructs the object graph returned by the query. In some workloads, removing tracking can result in EF Core creating considerably more objects than a tracking query would have created. And at that point, AsNoTracking() can become slower.

Tracking does more than track changes

Take an Order that belongs to a Customer.

public sealed class Order
{
    public Guid Id { get; init; }
    public Guid CustomerId { get; init; }
    public Customer Customer { get; init; } = null!;
    public decimal Total { get; init; }
}

public sealed class Customer
{
    public Guid Id { get; init; }
    public string Name { get; init; } = string.Empty;
}

Suppose one customer has 500 orders. Now query those orders together with their customer.

var orders = await dbContext.Orders
    .Include(x => x.Customer)
    .Where(x => x.CustomerId == customerId)
    .ToListAsync(stopToken);

At the SQL level, the customer data may appear repeatedly. Conceptually, the result resembles:

Order 1 | Customer 42 | Acme Ltd
Order 2 | Customer 42 | Acme Ltd
Order 3 | Customer 42 | Acme Ltd
Order 4 | Customer 42 | Acme Ltd
...
Order 500 | Customer 42 | Acme Ltd

There is one customer in the database. But its values appear throughout the result set. A tracking query performs identity resolution. When EF Core sees Customer 42 again, it checks the change tracker and discovers that an entity with that key has already been materialised. It reuses the existing instance. Microsoft describes this behaviour explicitly: tracking queries return an already tracked entity instance when an entity with the same key has previously been encountered. So the resulting graph can contain:

500 Order objects
1 Customer object

Every order references the same customer instance.

Order 1 ─┐
Order 2 ─┤
Order 3 ─┼──> Customer 42
Order 4 ─┤
...      │
Order 500┘

Now add AsNoTracking().

var orders = await dbContext.Orders
    .AsNoTracking()
    .Include(x => x.Customer)
    .Where(x => x.CustomerId == customerId)
    .ToListAsync(stopToken);

EF Core no longer uses the context's change tracker to perform identity resolution for ordinary no tracking queries, as Microsoft’s documentation confirms. This changes the query’s cost profile even when the database performance, generated SQL and network round trip remain effectively unchanged. The difference appears during materialisation. Without identity resolution, EF Core may create multiple CLR objects for repeated references to the same entity. In sufficiently repetitive object graphs, the allocation cost removed by disabling change tracking can therefore reappear elsewhere as additional object allocation and garbage collection overhead.

Identity resolution is the interesting part

This distinction is easy to miss because change tracking and identity resolution normally arrive together. With a normal tracking query EF Core effectively maintains a map of entities it has already materialised.

Conceptually:

Customer 42 -> Customer instance A
Customer 93 -> Customer instance B
Customer 107 -> Customer instance C

When another row refers to customer 42, EF Core can return the existing instance rather than constructing another Customer. That lookup isn’t free. EF Core must maintain internal data structures, compare entity keys and store tracking information. Microsoft specifically identifies the dictionary maintenance and key lookups required for identity resolution as sources of tracking overhead. However, those lookups can also prevent allocations. A query might return 10,000 rows that ultimately reference only 100 customers. Without identity resolution, the repeated customer data could produce thousands of separate CLR instances. With identity resolution, those references can converge on just 100 instances. This is why tracking cannot universally be described as slower. It exchanges the cost of identity lookups and tracking data structures for fewer object allocations when the result contains repeated entities.

EF Core gives us a third option

Fortunately, EF Core does not force us to choose only between full tracking and completely independent materialisation.

There is:

AsNoTrackingWithIdentityResolution()

For example:

var orders = await dbContext.Orders
    .AsNoTrackingWithIdentityResolution()
    .Include(x => x.Customer)
    .Where(x => x.CustomerId == customerId)
    .ToListAsync(stopToken);

The entities are still not attached to the context's normal change tracker. Changing them will not cause SaveChangesAsync() to persist those changes. But EF Core performs identity resolution while materialising the result. Microsoft implements this using a separate, temporary change tracker. Once enumeration has completed, that tracker is no longer required and can be garbage collected. That gives us three different behaviours. A normal tracking query gives us change tracking and identity resolution.

AsNoTracking() gives us neither.

AsNoTrackingWithIdentityResolution() gives us identity resolution without attaching the returned entities to the application's DbContext. For graph heavy read operations, this third behaviour can be extremely useful.

Take a more realistic query

Imagine an API returning orders together with their customer, account manager and products.

var orders = await dbContext.Orders
    .AsNoTracking()
    .Include(x => x.Customer)
    .Include(x => x.AccountManager)
    .Include(x => x.Items)
        .ThenInclude(x => x.Product)
    .Where(x => x.CreatedAt >= from)
    .ToListAsync(stopToken);

Suppose the result contains 2,000 orders. Those orders might reference 150 customers, 20 account managers and 300 products. Many of those entities occur repeatedly throughout the relational result. That repetition can be substantial. You can easily end up with relationships conceptually resembling:

Order 1001 ─── Customer 17
Order 1002 ─── Customer 17
Order 1003 ─── Customer 17
Order 1004 ─── Customer 17

Order 1001 ─── Product 81
Order 1027 ─── Product 81
Order 1042 ─── Product 81
Order 1198 ─── Product 81

A query using identity resolution can recognise repeated entity identities, while a plain no tracking query has no equivalent context level identity map. As repetition within the result increases, the number of allocations becomes more significant because EF Core may materialise multiple CLR objects representing the same entity. Although creating objects in .NET is relatively cheap, repeatedly creating enormous numbers of unnecessary objects under load isn’t. The higher allocation rate can cause more frequent Gen 0 collections and increase memory pressure across the service. A negligible difference for an individual query can therefore become visible when the endpoint is handling hundreds of requests per second.

Measure allocations, not just elapsed time

This is where EF Core benchmarking often becomes misleading. Developers run the query once and look at the duration.

Tracking:       42 ms
No tracking:    39 ms

Then AsNoTracking() wins. But three milliseconds tells you very little by itself. For application level performance work, I would want to know what happened to allocations as well. BenchmarkDotNet makes that easy.

[MemoryDiagnoser]
public class OrderQueryBenchmarks
{
    private readonly DbContextOptions<AppDbContext> options;

    public OrderQueryBenchmarks()
    {
        options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(ConnectionString)
            .Options;
    }

    [Benchmark]
    public async Task Tracking()
    {
        await using var dbContext = new AppDbContext(options);

        _ = await dbContext.Orders
            .Include(x => x.Customer)
            .Include(x => x.Items)
                .ThenInclude(x => x.Product)
            .ToListAsync();
    }

    [Benchmark]
    public async Task NoTracking()
    {
        await using var dbContext = new AppDbContext(options);

        _ = await dbContext.Orders
            .AsNoTracking()
            .Include(x => x.Customer)
            .Include(x => x.Items)
                .ThenInclude(x => x.Product)
            .ToListAsync();
    }

    [Benchmark]
    public async Task NoTrackingWithIdentityResolution()
    {
        await using var dbContext = new AppDbContext(options);

        _ = await dbContext.Orders
            .AsNoTrackingWithIdentityResolution()
            .Include(x => x.Customer)
            .Include(x => x.Items)
                .ThenInclude(x => x.Product)
            .ToListAsync();
    }
}

Now the comparison becomes more interesting. You can inspect execution time, allocated bytes and garbage collection activity. More importantly, benchmark against realistic data. Testing ten orders owned by ten different customers tells you almost nothing about an endpoint where 50,000 rows repeatedly reference the same few hundred entities. Performance depends heavily on the shape of the result.

There may be an even better answer

There is another issue with the previous query. Why are we materialising all those entities at all? If the API only needs a response DTO, loading a complete entity graph may be unnecessary. Suppose the endpoint returns this:

public sealed record OrderSummary(
    Guid Id,
    string CustomerName,
    string AccountManagerName,
    decimal Total);

Instead of this:

var orders = await dbContext.Orders
    .AsNoTracking()
    .Include(x => x.Customer)
    .Include(x => x.AccountManager)
    .ToListAsync(stopToken);

return orders.Select(x =>
    new OrderSummary(
        x.Id,
        x.Customer.Name,
        x.AccountManager.Name,
        x.Total));

project directly in the query.

var orders = await dbContext.Orders
    .Where(x => x.CreatedAt >= from)
    .Select(x => new OrderSummary(
        x.Id,
        x.Customer.Name,
        x.AccountManager.Name,
        x.Total))
    .ToListAsync(stopToken);

Now EF Core does not need to materialise Order, Customer and AccountManager entities simply so you can immediately transform them into another object. The generated SQL can retrieve only the columns required by the projection. Microsoft's EF Core performance guidance specifically recommends projecting only the properties required by the caller rather than retrieving entire entities unnecessarily. For many read endpoints, this is a much bigger optimisation than deciding between AsTracking() and AsNoTracking().

AsNoTracking() on a projection may tell you very little

This also leads to code I regularly see:

var orders = await dbContext.Orders
    .AsNoTracking()
    .Where(x => x.CustomerId == customerId)
    .Select(x => new OrderSummary(
        x.Id,
        x.Customer.Name,
        x.Total))
    .ToListAsync(stopToken);

The presence of AsNoTracking() gives the impression that an important optimisation has been applied. But if the projection contains no entity instances, there may be nothing meaningful for EF Core to track in the returned result anyway. Tracking behaviour becomes relevant when entity instances are present in the result. EF Core's documentation also notes that custom projections can still cause entities contained within those projections to be tracked.

For example:

var results = await dbContext.Orders
    .Select(x => new
    {
        Order = x,
        CustomerName = x.Customer.Name
    })
    .ToListAsync(stopToken);

Order is still an entity. So tracking behaviour remains relevant.

Compare that with:

var results = await dbContext.Orders
    .Select(x => new
    {
        x.Id,
        x.CustomerId,
        CustomerName = x.Customer.Name,
        x.Total
    })
    .ToListAsync(stopToken);

Now the result contains scalar values rather than Order entities. Understanding the shape of the projection is more useful than mechanically adding AsNoTracking() to every query.

Do not load no tracking entities just to attach them again

Another questionable pattern is querying entities without tracking and then attaching them later.

var order = await dbContext.Orders
    .AsNoTracking()
    .SingleAsync(x => x.Id == orderId, stopToken);

order.MarkAsPaid(); 
dbContext.Attach(order);
await dbContext.SaveChangesAsync(stopToken);

The intention is usually performance. Tracking was avoided during the query, so surely the operation must be cheaper. Except the application immediately asks EF Core to begin tracking the entity again. You have removed information that EF Core normally collects during materialisation and then introduced another step to reconstruct state later. Microsoft explicitly advises against routinely performing a no tracking query and then attaching those entities back to the same context, describing the approach as slower and harder to get right than using a tracking query.

If the purpose of the query is to load an aggregate, modify it and call SaveChangesAsync(), ordinary tracking is often exactly the behaviour you want.

var order = await dbContext.Orders
    .SingleAsync(x => x.Id == orderId, stopToken);
order.MarkAsPaid();
await dbContext.SaveChangesAsync(stopToken);

There is no prize for having the largest number of AsNoTracking() calls in a codebase.

Query shape dominates surprisingly quickly

Another reason to avoid focusing on tracking first is that other query decisions can dwarf its cost. An unindexed predicate or a result containing 100,000 rows can dominate the entire request, while loading several collections through joins can produce an enormous relational result. An application can also introduce an N+1 pattern by enabling lazy loading or explicitly querying related data inside a loop, adding dozens or hundreds of database round trips. Lazy loading isn’t enabled by EF Core’s default configuration, but Microsoft warns that it makes accidental N+1 queries particularly easy to introduce.

Selecting every column from a wide table when an endpoint needs only four can also create unnecessary database, network and materialisation work. Microsoft’s EF Core performance documentation makes the broader point that database execution, network latency and round trips will usually dominate EF Core’s own runtime overhead. Microsoft’s EF Core performance guidance

Changing:

AsTracking()

to:

AsNoTracking()

while ignoring a query performing a table scan is optimisation theatre.

Choose tracking behaviour deliberately

For an update operation, normal tracking is generally the natural choice.

var customer = await dbContext.Customers
    .SingleAsync(x => x.Id == customerId, stopToken);
customer.ChangeName(request.Name);
await dbContext.SaveChangesAsync(stopToken);

For a straightforward read only entity query with little duplication, AsNoTracking() is a sensible default.

var customers = await dbContext.Customers
    .AsNoTracking()
    .OrderBy(x => x.Name)
    .Take(100)
    .ToListAsync(stopToken);

For a read-only entity graph containing substantial repetition, test AsNoTrackingWithIdentityResolution().

var orders = await dbContext.Orders
    .AsNoTrackingWithIdentityResolution()
    .Include(x => x.Customer)
    .Include(x => x.Items)
        .ThenInclude(x => x.Product)
    .ToListAsync(stopToken);

And for API read models, reports and query endpoints, consider whether entities need to be materialised in the first place.

var orders = await dbContext.Orders
    .Where(x => x.CustomerId == customerId)
    .Select(x => new OrderSummary(
        x.Id,
        x.Customer.Name,
        x.Total))
    .ToListAsync(stopToken);

That final option is often where I would start.

AsNoTracking() is a tool, not a rule

AsNoTracking() remains one of the simplest performance improvements available in EF Core. For read only queries that materialise independent entities, avoiding change tracking can reduce both processing and memory overhead. But the optimisation comes with different materialisation semantics.

Ordinary tracking gives EF Core an identity map. Repeated occurrences of the same database entity can resolve to the same CLR instance. Plain AsNoTracking() removes that behaviour. When a result contains substantial entity repetition, the number of objects EF Core has to construct can therefore increase. AsNoTrackingWithIdentityResolution() exists for precisely this middle ground, allowing identity resolution during materialisation without leaving the entities attached to the application's context. And in many read heavy applications, projection removes the argument almost completely by avoiding entity materialisation altogether.

So when reviewing a query like:

dbContext.Orders
    .AsNoTracking()

I wouldn't automatically assume that this change has made the query faster. I would first examine what the query returns, how many entities it materialises, how often those entities are repeated and whether the caller needs complete entities at all. I would then compare the memory allocated by each version before benchmarking the real query against realistic data.

AsNoTracking() frequently produces the better result, but "frequently" should never be mistaken for "always".