AsNoTracking vs AsNoTrackingWithIdentityResolution

AsNoTracking vs AsNoTrackingWithIdentityResolution

7 min read··

dotnetef-coreperformance

Use AsNoTracking for read-only queries whose results you map to DTOs, and AsNoTrackingWithIdentityResolution when the result is an object graph you will traverse. Both skip the change tracker, but only identity resolution keeps a temporary identity map, so shared related entities resolve to one instance per key instead of a fresh copy per row. That deduplication costs a little CPU per row.

AsNoTracking is probably the most repeated EF Core performance tip on the internet, and it is good advice. I give it myself.

But it has a side effect that almost nobody mentions. Query 100 orders with their customer, where all 100 belong to the same customer, and you get 100 separate Customer objects in memory. Same key, same data, 100 instances. There is a third query mode that fixes exactly this, and knowing when to reach for it is the point of this article.

The Three Query Modes

Every EF Core query runs in one of three modes:

Tracking (the default). Every materialized entity is registered in the change tracker with a snapshot of its original values. That is what makes SaveChanges work, and it is also why the change tracker maintains an identity map: one instance per key, guaranteed. Read the same row twice, get the same object reference.

No tracking.

var orders = await dbContext.Orders
    .Include(o => o.Customer)
    .AsNoTracking()
    .ToListAsync();

No snapshots, no change tracker entries, no identity map. EF materializes a fresh object for whatever each row says, hands it to you, and forgets it existed. This is the right default for read-only queries, and you will find it in every EF Core performance guide for good reason.

No tracking with identity resolution.

var orders = await dbContext.Orders
    .Include(o => o.Customer)
    .AsNoTrackingWithIdentityResolution()
    .ToListAsync();

Still untracked, still read-only. But during materialization, EF keeps a temporary identity map: the first time it sees Customer 42 it creates the instance, and every subsequent row with CustomerId = 42 gets a reference to that same instance. The map is thrown away when the query completes.

Seeing the Duplication

The behavior difference is easiest to see with reference equality. Take 100 orders that all belong to one customer:

// Plain AsNoTracking
var orders = await dbContext.Orders
    .Where(o => o.CustomerId == customerId)
    .Include(o => o.Customer)
    .AsNoTracking()
    .ToListAsync();

var distinctInstances = orders
    .Select(o => o.Customer)
    .Distinct(ReferenceEqualityComparer.Instance)
    .Count();

Console.WriteLine(distinctInstances); // 100

One hundred Customer objects, all with the same primary key, all carrying identical copies of every column. Swap in AsNoTrackingWithIdentityResolution and the same code prints 1.

The same query of 100 orders sharing one customer materializes 100 duplicated Customer objects under AsNoTracking, but a single shared Customer instance under AsNoTrackingWithIdentityResolution

To be precise about what is duplicated: this is not 100 customer rows coming over the wire in this shape (the JOIN repeats customer columns per row either way, that part is inherent to SQL). The duplication is in materialization: 100 allocated objects instead of 1, and 100x the memory for that entity's data.

If the customer row is wide (a JSON column, a description, a byte array), the memory multiplication gets serious. 1,000 rows sharing 10 customers with 2 KB of customer data each: tracking or identity resolution holds 20 KB of customer data, plain AsNoTracking holds 2 MB.

When Do the Duplicates Actually Hurt?

Most of the time, duplicated instances are harmless. You map the rows to a DTO, serialize, respond, and the garbage collector cleans up. That is why plain AsNoTracking is still the right default.

The duplication bites in three specific situations:

1. In-memory graph processing. Any logic that assumes "same entity means same object" breaks quietly. Grouping by reference, building lookups keyed by instance, walking the object graph and mutating shared nodes: with plain no-tracking, "shared" nodes are not shared, so you update one copy of the customer and the other 99 still show the old value.

2. Memory-heavy read paths. Reporting queries with wide shared parents, exports, or anything that holds large result sets alive while processing. Here the duplication is a real allocation and GC cost, not a rounding error.

3. Cyclic or diamond-shaped Includes. Multiple include paths reaching the same entity produce independent copies of it. Serializers configured with reference preservation then see distinct objects instead of one, and payload sizes balloon.

Notice what all three have in common: you are treating the result as a graph, not as rows. That is the heuristic. Rows to DTOs: AsNoTracking. A graph you will traverse: identity resolution (or projection, more on that below).

What Identity Resolution Costs

Nothing is free. AsNoTrackingWithIdentityResolution maintains a dictionary of materialized keys for the duration of the query, so it pays lookup and bookkeeping CPU per row, plus the map itself.

The expected cost profile is:

  • AsNoTracking: fastest, most allocations when parents are shared.
  • AsNoTrackingWithIdentityResolution: slightly slower per row, but allocations drop as sharing grows.
  • Tracking: the expensive one, because snapshots and change tracker registration dwarf the identity map cost.

The break-even depends on the share ratio. With no shared entities at all (every order has a distinct customer), the identity map is pure overhead: skip it. With high sharing, the reduced allocations can pay the CPU back, and the semantic correctness comes free. Benchmark the actual query instead of treating general ratios as a guarantee.

Here is how the two modes compare, dimension by dimension:

AsNoTrackingAsNoTrackingWithIdentityResolution
Change tracker entriesNoneNone
Identity mapNoneTemporary, discarded when the query completes
Shared related entitiesA fresh instance per rowOne shared instance per key
Per-row costLowestSlightly higher, a dictionary lookup per row
Allocations when parents are sharedHighestDrop as sharing grows
Usable with SaveChangesNoNo
Best forRows you map to a DTO and returnGraphs you traverse, group, or serialize with references
As the context-wide defaultThe one to set for read-heavy appsPays the map cost on every query

Setting a Default (and Overriding It)

You can make no-tracking the context-wide default, which I do for read-heavy applications and in the query side of CQRS:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(connectionString)
        .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

QueryTrackingBehavior.NoTrackingWithIdentityResolution is also valid as a default, but it pays the identity-map cost on every query to fix a problem many queries do not have. Set NoTracking as the default and opt up per query:

// Read model: default no-tracking applies.
var summaries = await dbContext.Orders
    .Select(o => new OrderSummary(o.Id, o.Customer.Name, o.Total))
    .ToListAsync();

// Graph-shaped read: opt into identity resolution.
var graph = await dbContext.Orders
    .Include(o => o.Customer)
    .Include(o => o.Lines).ThenInclude(l => l.Product)
    .AsNoTrackingWithIdentityResolution()
    .ToListAsync();

// Command: opt back into tracking to modify.
var order = await dbContext.Orders
    .AsTracking()
    .FirstAsync(o => o.Id == orderId);

One warning for the no-tracking-by-default setup: it is the classic source of "I changed the entity and SaveChanges did nothing". If updates silently stop working after you flip the default, that is why, and it is a cousin of the already-tracked errors you get when mixing modes carelessly.

The Option That Beats Both: Projection

Before choosing between the two no-tracking flavors, ask whether you need entities at all.

var report = await dbContext.Orders
    .Where(o => o.CreatedAt >= from)
    .Select(o => new OrderReportRow(
        o.Id,
        o.Customer.Name,
        o.Lines.Sum(l => l.Quantity * l.UnitPrice)))
    .ToListAsync();

A Select into a DTO sidesteps the entire question: nothing is tracked, nothing is duplicated, only the needed columns cross the wire, and the identity map never enters the picture. Projections do not need AsNoTracking at all, because there are no entities to track. For pure read endpoints this wins on every axis, and skipping it is one of the EF Core query mistakes I see most often. The same materialization behavior also underlies query splitting decisions: how EF turns rows into objects is worth understanding once, deeply.

Entities (and therefore this article's choice) remain relevant when you genuinely want the object graph: domain-shaped reads, mappers that consume entities, or code paths shared with tracking scenarios.

Summary

  • AsNoTracking skips the change tracker AND the identity map. Every row materializes a fresh instance, so shared parents are duplicated: 100 orders with one customer means 100 Customer objects.
  • AsNoTrackingWithIdentityResolution keeps queries untracked but deduplicates by key during materialization, at the price of a per-query identity map.
  • Decide by shape: rows (map to DTO and return) take plain AsNoTracking; graphs (traverse, group, serialize with references) take identity resolution.
  • Set NoTracking as the context default for read-heavy apps, opt into AsTracking for commands, and reach for identity resolution explicitly where sharing matters.
  • The best version of a read query is often a projection, which makes the whole dilemma disappear.

The tip "use AsNoTracking for reads" is useful but incomplete. The missing piece is knowing that the identity map was doing quiet work for you, and knowing the one-line fix when its absence shows.

Frequently Asked Questions

What does AsNoTracking do in EF Core?

It runs the query without registering the results in the change tracker. EF skips snapshot creation and identity resolution, which makes read-only queries faster and less memory-hungry, but the returned entities cannot be updated through SaveChanges.

Why does AsNoTracking create duplicate entities?

Without the change tracker, EF has no identity map, so it materializes a fresh object for every row it reads. If 100 orders share one customer, an Include gives you 100 separate Customer instances with the same key.

What is AsNoTrackingWithIdentityResolution?

A query mode that stays untracked but uses a temporary identity map during materialization. Rows with the same key resolve to a single shared instance, so shared related entities are no longer duplicated.

Is AsNoTrackingWithIdentityResolution slower than AsNoTracking?

Slightly. It maintains a dictionary of seen keys during materialization, which costs some CPU and allocations. It is still cheaper than full tracking because no snapshots are created and nothing is registered for change detection.

Should I use AsNoTracking by default?

For read-only queries, yes. You can set QueryTrackingBehavior.NoTracking as the DbContext default and opt back into tracking only in commands that modify data.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.