Entity Framework Core

It's a tool, not a rule

"AsNoTracking is faster" is the kind of advice that survives because it is usually true. It is also missing half the story: AsNoTracking does not just switch off change tracking, it switches off identity resolution too. We benchmarked all three tracking modes against a SQLite in-memory database to see where that actually costs you.

Tracking (default)
Full change tracking. Same entity, same instance, every time - at the cost of a snapshot per row.
AsNoTracking
No change tracker, no identity map either: every row builds a fresh instance, even for a Blog shared by 500 Posts.
AsNoTrackingWithIdentityResolution
No change tracker, but the identity map is back: shared entities are one instance again.
Claim

"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.

Identity

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:

The same query, three ways
// 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();
40

Tracking (default) - 20,000 rows in, 40 distinct Blog instances out

20,000

AsNoTracking - 20,000 rows in, 20,000 distinct Blog instances out

40

AsNoTrackingWithIdentityResolution - 20,000 rows in, 40 distinct Blog instances out

Benchmark

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.

SQLite in-memory setup
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.

Verdict

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.

QueryContext.cs, dotnet/efcore (simplified)
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;
Choice

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.

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.

0 / 2000
or

You can also reach us directly at [email protected]