"Just add AsNoTracking, it's faster"
It is good advice, most of the time. AsNoTracking tells EF Core to skip snapshotting: no ChangeTracker entry, no copy of the original values, no proxy watching the object for changes. For a read-only endpoint that just projects data onto a response, that is close to free performance, and every EF Core performance checklist recommends it.
What that advice usually leaves out: the change tracker is also where identity resolution lives. Turn it off, and you turn that off too. EF Core's own source code says so plainly: AsNoTracking's documentation states that identity resolution is not performed, and that an entity with a given key appearing more than once in a result set produces a different instance every time it appears.
One Blog, five hundred Posts, one line of code
Take a Blog with 500 Posts and query the Posts with Include(p => p.Blog). With tracking on, EF Core recognizes the same Blog key coming back 500 times and gives you 500 references to one object. Turn on AsNoTracking, and it stops recognizing that: every row builds a new Blog, even though the underlying data is identical. Nothing crashes, but ReferenceEquals(posts[0].Blog, posts[1].Blog) silently flips from true to false, and any code that relies on it (caching by reference, mutating a shared in-memory graph, deduplicating for a UI) breaks quietly.
AsNoTrackingWithIdentityResolution exists for exactly this gap: no change tracker entries (so no SaveChanges support, same as AsNoTracking), but the identity map comes back, so repeated keys resolve to one shared instance again. We ran the same query against a SQLite in-memory database and counted the actual object instances:
// 40 Blogs, 500 Posts each - every Blog is referenced by 500 rows.
var tracked = context.Posts.Include(p => p.Blog)
.ToList();
var noTracking = context.Posts.Include(p => p.Blog)
.AsNoTracking()
.ToList();
var withIdentityResolution = context.Posts.Include(p => p.Blog)
.AsNoTrackingWithIdentityResolution()
.ToList();Tracking (default) - 20,000 rows in, 40 distinct Blog instances out
AsNoTracking - 20,000 rows in, 20,000 distinct Blog instances out
AsNoTrackingWithIdentityResolution - 20,000 rows in, 40 distinct Blog instances out
So AsNoTrackingWithIdentityResolution just wins, right?
Not quite, or rather: it depends. Correctness is not the same question as performance, and the answer to "which is faster" depends entirely on your query's shape, how many rows come back and how many of them share the same key. We measured three shapes with BenchmarkDotNet 0.15.8 against a Microsoft.Data.Sqlite in-memory database, a single kept-open connection backing DataSource=:memory: so there is no external database to skew the numbers, on an Apple M2 Pro running .NET 10.0.11.
Scenarios 1 and 2 reuse the same small Blog/Post pair from above: 40 blogs, 500 posts each, 20,000 posts total. Scenario 3 swaps in a much heavier shared entity and a much higher fan-out, to see whether that changes the answer.
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open(); // kept open for the run's lifetime - closing it drops the data
var options = new DbContextOptionsBuilder<BlogContext>()
.UseSqlite(connection)
.Options;Scenario 1: shared references (Include with fan-out)
context.Posts.Include(p => p.Blog).ToList() - every one of the 20,000 rows references one of only 40 Blog entities.
| Method | Mean | Allocated | Time Ratio | Alloc Ratio |
|---|---|---|---|---|
| Tracking (default) | 51.36 ms | 28.37 MB | 1.00x | 1.00x |
| AsNoTracking | 33.51 ms | 22.78 MB | 0.65x | 0.80x |
| AsNoTrackingWithIdentityResolution | 52.77 ms | 35.39 MB | 1.03x | 1.25x |
Scenario 2: no shared references (a flat query)
context.Posts.ToList() - same 20,000 rows, but no Include and nothing to deduplicate. Every Post is already unique.
| Method | Mean | Allocated | Time Ratio | Alloc Ratio |
|---|---|---|---|---|
| Tracking (default) | 29.68 ms | 23.96 MB | 1.00x | 1.00x |
| AsNoTracking | 17.13 ms | 11.52 MB | 0.58x | 0.48x |
| AsNoTrackingWithIdentityResolution | 29.28 ms | 23.96 MB | 0.99x | 1.00x |
Scenario 3: a heavy shared entity, high fan-out
context.Purchases.Include(o => o.Customer).ToList() - a 14-column Customer, referenced by 2,000 Purchases each: 50 customers, 100,000 rows total.
| Method | Mean | Allocated | Time Ratio | Alloc Ratio |
|---|---|---|---|---|
| Tracking (default) | 229.4 ms | 110.21 MB | 1.00x | 1.00x |
| AsNoTracking | 334.5 ms | 175.00 MB | 1.46x | 1.59x |
| AsNoTrackingWithIdentityResolution | 257.3 ms | 145.31 MB | 1.12x | 1.32x |
Here the ranking flips. AsNoTrackingWithIdentityResolution beats AsNoTracking by roughly 23% on time and 17% on memory. It still does not catch full tracking, but between the two no-tracking options, the one everyone assumes is "always slower" wins outright, because it only has to build each wide Customer once instead of 2,000 times over.
Measured on: Apple M2 Pro · .NET 10.0.11 · BenchmarkDotNet v0.15.8 · MemoryDiagnoser
None of the three modes wins across the board, which is the actual point. In scenarios 1 and 2, where the shared entity is tiny or there is nothing shared at all, AsNoTracking is the cheapest option on every metric and AsNoTrackingWithIdentityResolution never earns back what it spends standing up its own state manager. In scenario 3, where the shared entity is wide and reused 2,000 times over, that spending pays off: AsNoTrackingWithIdentityResolution beats AsNoTracking on both time and memory, because it skips re-materializing the same Customer tens of thousands of times. Same two options, opposite winner, only the data shape changed.
Identity resolution is not a free HashSet
The EF Core source explains the number. QueryContext.InitializeStateManager takes a standAlone flag: when true, EF Core spins up a stand-alone IStateManager purely to perform identity resolution, instead of reusing the DbContext's own one. AsNoTrackingWithIdentityResolution does not bolt a lightweight lookup table onto AsNoTracking: for every materialized entity it builds the same InternalEntityEntry and ISnapshot objects that a fully tracked query builds, just inside that throwaway state manager, which never attaches to your DbContext and never participates in SaveChanges.
That machinery costs the same whether or not it ever gets used, which is why scenario 2's allocations land exactly on full tracking's number even though there was nothing to deduplicate. But it also buys something concrete: EF Core skips full re-materialization for a key it already has an entry for, so it never re-reads or re-allocates that entity's columns again. When the entity is small and rarely repeated, as in scenarios 1 and 2, there is nothing worth skipping and you are just paying for the state manager. When the entity is wide and reused often, as in scenario 3, the cost of skipping tens of thousands of redundant materializations outweighs that fixed overhead, and AsNoTrackingWithIdentityResolution pulls ahead of AsNoTracking.
public void InitializeStateManager(bool standAlone = false)
// standAlone: true for AsNoTrackingWithIdentityResolution.
// A throwaway StateManager is built just for this query's identity map;
// it is never attached to the DbContext and never sees SaveChanges.
=> _stateManager ??= standAlone
? new StateManager(Dependencies.StateManager.Dependencies)
: Dependencies.StateManager;None of the three is the default answer
The right mode depends on what your query actually does with the result, and on the size and repetition of the data it returns, not on which one benchmarks best in isolation.
Tracking (default)
You are going to mutate the entities and call SaveChanges. This is the only mode of the three that supports that, full stop.
AsNoTracking
Read-only, and whatever gets duplicated is small: a flat list, a single table, or a narrow shared entity like our Blog. This is where the folk wisdom holds: cheapest in both time and memory, as long as there is not much to duplicate in the first place.
AsNoTrackingWithIdentityResolution
Read-only, and either your code depends on reference equality (a shared navigation property compared with ==, a graph serialized once per unique instance), or your shared entities are wide and heavily reused. The first case is a correctness trade you pay for on purpose. The second is a genuine performance win: our heavy-entity benchmark shows it beating AsNoTracking by 23% on time and 17% on memory, simply by not rebuilding the same wide row thousands of times over.
Key takeaway
AsNoTracking, AsNoTrackingWithIdentityResolution and full tracking each buy a different mix of change tracking and identity resolution, and none of them is free or universally best. Across our three benchmarks, AsNoTracking won outright when the shared data was small or absent, but AsNoTrackingWithIdentityResolution beat it by 23% on time and 17% on memory once the shared entity got wide and heavily reused. Whether identity resolution is worth its overhead depends entirely on the shape of your data, not on which one you reached for last time. That is not a rule you memorize, it is a tool you point at your own workload and measure.
More .NET services
Get In Touch
Let's Build Something Great Together
Have a question or want to discuss a project? We'd love to hear from you. Fill out the form below and we'll get back to you as soon as possible.
You can also reach us directly at [email protected]