Every time you run a query through Entity Framework Core, something happens behind the scenes that most developers never think about: EF Core takes a snapshot of every entity it returns and stores it in an internal change tracker. When you call SaveChanges(), it compares the current state of each entity against that snapshot to figure out what changed. This is how EF Core “magically” knows which rows to update.
That magic has a cost. For every entity returned from a tracking query, EF Core allocates memory for the snapshot, maintains internal data structures for identity resolution, and runs comparison logic on SaveChanges(). When you’re loading 10 records for a detail page, the overhead is negligible. When you’re loading 10,000 records for a report or a dashboard, it adds up fast - both in memory and in CPU time.
The fix is straightforward: if you’re not going to modify the data, tell EF Core not to track it. But knowing when and how to do this - and understanding the trade-offs - is where most tutorials fall short. We’ll cover all of that here, with real benchmarks to back it up.
Let’s get into it.
What is Change Tracking in EF Core?
Change tracking is the mechanism EF Core (Entity Framework Core) uses to detect modifications to entity instances so it can generate the correct SQL statements on SaveChanges(). When you query entities from the database, EF Core stores a snapshot of their original property values in its internal ChangeTracker. On SaveChanges(), it compares each entity’s current values against that snapshot and generates INSERT, UPDATE, or DELETE statements only for what actually changed.
Every tracked entity is in one of five states:
| State | Meaning | What Happens on SaveChanges |
|---|---|---|
| Unchanged | Entity was loaded from DB, no modifications detected | Nothing - skipped |
| Modified | One or more properties changed since loading | Generates UPDATE |
| Added | New entity attached to the context | Generates INSERT |
| Deleted | Entity marked for deletion | Generates DELETE |
| Detached | Entity is not being tracked by this context | Nothing - invisible to EF Core |
Here’s a minimal example of change tracking in action:
var movie = await db.Movies.FirstOrDefaultAsync(m => m.Id == movieId, ct);// At this point, EF Core has a snapshot of movie's original values.// Entity state: Unchanged
movie.Rating = 9.5m;// Entity state automatically transitions to: Modified
await db.SaveChangesAsync(ct);// EF Core compares current values vs snapshot, generates:// UPDATE "Movies" SET "Rating" = 9.5 WHERE "Id" = @p0The change tracker also performs identity resolution - if the same entity (same primary key) appears in multiple query results, EF Core returns the same object instance rather than creating duplicates. This ensures consistency within a single DbContext lifetime. For more on how EF Core’s change tracker works internally, see the official Change Tracking documentation.
This is critical to understand: change tracking isn’t just about detecting modifications. It also controls identity resolution, navigation property fix-up (automatically populating related entity references), and memory allocation. When you turn off tracking, you turn off all of these behaviors.
Tracking Queries - The Default Behavior
By default, every query that returns entity types is a tracking query. This means EF Core keeps a reference to each returned entity in the ChangeTracker, along with a snapshot of its original property values.
// This is a tracking query (default behavior)var movies = await db.Movies.ToListAsync(ct);
// EF Core now tracks all returned Movie entities.// Any changes you make will be detected on SaveChanges().Why Tracking Queries Exist
Tracking queries are essential for CRUD operations. Without them, EF Core wouldn’t know what changed between the time you loaded an entity and the time you called SaveChanges().
Consider a typical update endpoint in a Web API:
app.MapPut("/api/movies/{id:guid}", async (Guid id, UpdateMovieRequest request, MovieDbContext db, CancellationToken ct) =>{ // Tracking query - EF Core snapshots the original values var movie = await db.Movies.FindAsync([id], ct); if (movie is null) return Results.NotFound();
// Modify the entity - state changes from Unchanged to Modified movie.Title = request.Title; movie.Rating = request.Rating;
// EF Core compares current vs snapshot, generates targeted UPDATE await db.SaveChangesAsync(ct); return Results.Ok(movie);});This only works because EF Core tracked the entity from the moment it was loaded. It knows the original Title and Rating, so it can generate a precise UPDATE statement that only touches the columns you changed - not the entire row.
Identity Resolution in Tracking Queries
When a tracking query returns the same entity multiple times (common with joins and includes), EF Core returns the same object reference for each occurrence. This prevents duplicate objects in memory and ensures consistency.
// Both queries return the same Movie object in memory (same reference)var movie1 = await db.Movies.FirstOrDefaultAsync(m => m.Id == movieId, ct);var movie2 = await db.Movies.FirstOrDefaultAsync(m => m.Id == movieId, ct);
Console.WriteLine(ReferenceEquals(movie1, movie2)); // trueEF Core also performs navigation property fix-up - when related entities are loaded, it automatically connects them through their navigation properties. If you load a Movie and its Director is already tracked, EF Core populates the Movie.Director property automatically.
No-Tracking Queries with AsNoTracking
No-tracking queries bypass the change tracker entirely. EF Core doesn’t create snapshots, doesn’t perform identity resolution, and doesn’t fix up navigation properties. The entities come back as plain objects - fast, lightweight, and completely disconnected from the DbContext.
// No-tracking query - entities are not trackedvar movies = await db.Movies .AsNoTracking() .ToListAsync(ct);This is the single most impactful performance optimization you can apply to read-only queries in EF Core. According to Microsoft’s official EF Core documentation, no-tracking queries are “generally quicker to execute because there’s no need to set up the change tracking information.”
When to Use AsNoTracking
Use AsNoTracking() whenever you’re reading data that you won’t modify. In a Web API context, this covers the majority of your endpoints:
GET /api/movies- List endpoints that return data for displayGET /api/movies/{id}- Detail endpoints where you’re just returning data- Search, filtering, and report endpoints
- Any endpoint that doesn’t call
SaveChanges()
// Read-only list endpoint - perfect for AsNoTrackingapp.MapGet("/api/movies", async (MovieDbContext db, CancellationToken ct) =>{ var movies = await db.Movies .AsNoTracking() .OrderByDescending(m => m.Rating) .Take(50) .ToListAsync(ct);
return Results.Ok(movies);});No Identity Resolution (By Default)
With AsNoTracking(), if the same entity appears multiple times in a query result, EF Core creates a new object instance for each occurrence. This can matter when you use .Include() to load related data.
For example, if 50 movies share 5 directors, a no-tracking query with .Include(m => m.Director) creates 50 separate Director objects - one per movie - even though there are only 5 unique directors. Each movie gets its own copy of the Director.
var movie1 = await db.Movies.AsNoTracking() .FirstOrDefaultAsync(m => m.Id == movieId, ct);var movie2 = await db.Movies.AsNoTracking() .FirstOrDefaultAsync(m => m.Id == movieId, ct);
Console.WriteLine(ReferenceEquals(movie1, movie2)); // false - different objects!For most API scenarios, this doesn’t matter - you’re serializing to JSON and sending it to the client. But if you’re doing in-memory processing where object identity matters, this is something to be aware of.
AsNoTrackingWithIdentityResolution - The Middle Ground
EF Core provides a third option that combines the performance benefits of no-tracking with the identity resolution of tracking queries: AsNoTrackingWithIdentityResolution().
var movies = await db.Movies .AsNoTrackingWithIdentityResolution() .Include(m => m.Director) .ToListAsync(ct);With this approach:
- Entities are not tracked - changes won’t be detected,
SaveChanges()won’t persist modifications - Identity resolution is performed - if 50 movies share 5 directors, you get 5 Director instances (not 50 duplicates)
- A standalone, temporary change tracker runs in the background during query materialization, then gets garbage collected
When to Use It
Use AsNoTrackingWithIdentityResolution() when all three conditions are true:
- You’re reading data (no modifications)
- You’re loading related entities with
.Include() - The related entities are shared across multiple parent entities (e.g., many movies share the same director, many users share the same role)
If you’re not using .Include() or your related entities aren’t shared, plain AsNoTracking() is simpler and slightly faster.
Important caveat:
AsNoTrackingWithIdentityResolution()does not support cycles in Include paths. If entity A references entity B which references entity A, you’ll get a runtime error. Structure your queries to avoid circular includes.
Tracking Behavior with Projections
Here’s something most developers miss: projections (Select) bypass tracking automatically when the result type is not an entity.
// This is NOT tracked - result is an anonymous type, not an entityvar movieSummaries = await db.Movies .Select(m => new { m.Title, m.Genre, m.Rating }) .ToListAsync(ct);Since the result is an anonymous type (not a Movie entity), EF Core doesn’t track it. You get the performance benefits of no-tracking without explicitly calling AsNoTracking(). This is the documented behavior from Microsoft.
However, if your projection includes actual entity instances, those entities are tracked:
// The Blog entity IS tracked - it's an entity type in the resultvar result = await db.Movies .Select(m => new { Movie = m, DirectorName = m.Director!.Name }) .ToListAsync(ct);// m (Movie) is tracked. DirectorName (string) is not.The rule: If the result set doesn’t contain any entity type instances - just scalars, anonymous types, or DTOs - no tracking happens. If it contains entity instances, those are tracked by default.
Pro tip: Projections are often better than
AsNoTracking()for API responses because you only fetch the columns you need. Less data transferred from the database, less memory allocated, and no tracking overhead. Use projections for list endpoints whenever possible.
Performance Benchmarks - Tracking vs. AsNoTracking vs. Identity Resolution
Let’s stop guessing and measure. Here’s a BenchmarkDotNet comparison of the three tracking modes across different dataset sizes. The benchmark loads all entities from a seeded PostgreSQL database using EF Core 10 on .NET 10.
Benchmark Setup
[MemoryDiagnoser][SimpleJob(RuntimeMoniker.Net100)]public class TrackingBenchmarks{ private MovieDbContext _db = null!;
[Params(100, 1_000, 10_000)] public int RowCount { get; set; }
[GlobalSetup] public void Setup() { var options = new DbContextOptionsBuilder<MovieDbContext>() .UseNpgsql("Host=localhost;Database=tracking_bench;Username=postgres;Password=postgres") .Options; _db = new MovieDbContext(options); // Database pre-seeded with RowCount movies + directors }
[Benchmark(Baseline = true)] public async Task<List<Movie>> Tracking() => await _db.Movies.Take(RowCount).ToListAsync();
[Benchmark] public async Task<List<Movie>> NoTracking() => await _db.Movies.AsNoTracking().Take(RowCount).ToListAsync();
[Benchmark] public async Task<List<Movie>> NoTrackingWithIdentityResolution() => await _db.Movies.AsNoTrackingWithIdentityResolution() .Include(m => m.Director).Take(RowCount).ToListAsync();
[GlobalCleanup] public void Cleanup() => _db.Dispose();}Benchmark Results
| Method | Rows | Mean | Ratio | Allocated |
|---|---|---|---|---|
| Tracking | 100 | 1.8 ms | 1.00 | 312 KB |
| NoTracking | 100 | 1.2 ms | 0.67 | 186 KB |
| NoTracking + IdentityRes | 100 | 1.4 ms | 0.78 | 218 KB |
| Tracking | 1,000 | 8.4 ms | 1.00 | 2.8 MB |
| NoTracking | 1,000 | 4.1 ms | 0.49 | 1.4 MB |
| NoTracking + IdentityRes | 1,000 | 5.2 ms | 0.62 | 1.9 MB |
| Tracking | 10,000 | 68 ms | 1.00 | 26.1 MB |
| NoTracking | 10,000 | 31 ms | 0.46 | 12.3 MB |
| NoTracking + IdentityRes | 10,000 | 42 ms | 0.62 | 17.8 MB |
These benchmarks were run on .NET 10 with EF Core 10 against PostgreSQL 17. Your numbers will vary based on hardware, query complexity, and entity size - always benchmark your own workload.
What the Numbers Tell Us
- AsNoTracking is ~2x faster than tracking queries at scale (10K rows: 31ms vs 68ms)
- Memory allocation drops by ~50% - the change tracker’s snapshot storage is the biggest cost
- Identity resolution adds overhead compared to plain
AsNoTracking()but is still faster than full tracking - At 100 rows, the difference is small (1.8ms vs 1.2ms) - don’t over-optimize endpoints that return small datasets
The takeaway: for endpoints returning dozens of records, tracking mode barely matters. For endpoints returning hundreds or thousands of records, AsNoTracking() can cut response time and memory usage in half. For a deeper dive into EF Core query performance strategies, see Microsoft’s performance guide.
When to Use Each - A Practical Guide for Web APIs
Here’s the decision guide we wish every EF Core tutorial included:
| Scenario | Tracking Mode | Why |
|---|---|---|
| GET list endpoint (paginated, filtered) | AsNoTracking() | Read-only, often returns many rows - biggest performance win |
| GET detail endpoint (display only) | AsNoTracking() | Read-only, single entity, no modification needed |
| GET detail → then UPDATE (same request) | Default (tracking) | Need change detection for SaveChanges |
GET with .Include() + shared related entities | AsNoTrackingWithIdentityResolution() | Prevents duplicate related objects in memory |
Projections with Select | No annotation needed | Anonymous types and DTOs are never tracked |
| Batch import / bulk read in a loop | AsNoTracking() + ChangeTracker.Clear() | Prevents change tracker from accumulating entities across iterations |
| Background job reading large datasets | AsNoTracking() | Minimize memory pressure for long-running operations |
Batch Processing with ChangeTracker.Clear()
When processing large datasets in a loop, tracked entities accumulate in the ChangeTracker, gradually consuming more memory and slowing down SaveChanges(). Use ChangeTracker.Clear() to reset the tracker between batches:
var batchSize = 500;var totalMovies = await db.Movies.CountAsync(ct);
for (int skip = 0; skip < totalMovies; skip += batchSize){ var batch = await db.Movies .OrderBy(m => m.Id) .Skip(skip) .Take(batchSize) .ToListAsync(ct);
foreach (var movie in batch) { movie.Rating = RecalculateRating(movie); }
await db.SaveChangesAsync(ct);
// Clear the change tracker - release all tracked entities db.ChangeTracker.Clear();}Without ChangeTracker.Clear(), by the time you process the 10th batch, the context is holding onto 5,000 tracked entities - all consuming memory and making each subsequent SaveChanges() call slower as EF Core scans through them.
Configuring Default Tracking Behavior
If most of your queries are read-only - which is typical for Web APIs - you can change the default tracking behavior at the DbContext level so all queries are no-tracking by default.
Option 1: Configure in DbContextOptionsBuilder (Recommended)
Set the default when registering the DbContext:
builder.Services.AddDbContext<MovieDbContext>(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")) .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));With this configuration, all queries are no-tracking by default. When you need tracking for an update operation, explicitly opt in with AsTracking():
// Default is now NoTracking - this query is no-tracking automaticallyvar movies = await db.Movies.ToListAsync(ct);
// Opt in to tracking for update scenariosvar movie = await db.Movies .AsTracking() .FirstOrDefaultAsync(m => m.Id == id, ct);movie.Title = "Updated Title";await db.SaveChangesAsync(ct);Option 2: Configure Per-Context Instance
You can also change the default at runtime for a specific context instance:
db.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;This is useful when you receive a DbContext through dependency injection and want to override the default for a specific service or handler.
Which approach should you use? For most Web APIs, configuring
NoTrackingas the default (Option 1) is the right call. The majority of endpoints are read-only, and the few update endpoints can explicitly useAsTracking(). This way, new endpoints are fast by default, and developers must consciously opt in to tracking when they actually need it.
Common Mistakes and How to Fix Them
Mistake 1: Trying to SaveChanges on No-Tracking Entities
This is the most common mistake. You load an entity with AsNoTracking(), modify it, and call SaveChanges() - nothing happens. No error, no exception, just silence. EF Core doesn’t know the entity exists because it’s not in the change tracker.
// BUG: This update will be silently ignored!var movie = await db.Movies.AsNoTracking() .FirstOrDefaultAsync(m => m.Id == id, ct);movie!.Title = "New Title";await db.SaveChangesAsync(ct); // Does nothing - movie isn't trackedFix: Either remove AsNoTracking() for update scenarios, or explicitly attach the entity:
// Option 1: Use a tracking query (preferred)var movie = await db.Movies.FirstOrDefaultAsync(m => m.Id == id, ct);
// Option 2: Attach a detached entity and mark as modifieddb.Movies.Update(movie);await db.SaveChangesAsync(ct);Mistake 2: Forgetting AsTracking When Default is NoTracking
When you’ve configured NoTracking as the default (which we recommended above), it’s easy to forget that update endpoints need explicit tracking:
// BUG: If default is NoTracking, this update silently fails!var movie = await db.Movies.FindAsync([id], ct);movie!.Rating = 9.0m;await db.SaveChangesAsync(ct);Note:
FindAsyncrespects the default tracking behavior configuration. If your default isNoTracking,FindAsyncalso returns untracked entities.
Fix: Always use AsTracking() in update endpoints when the default is NoTracking:
var movie = await db.Movies.AsTracking() .FirstOrDefaultAsync(m => m.Id == id, ct);Mistake 3: Using AsNoTracking with Long-Lived DbContext
If you’re using a DbContext with a long lifetime (e.g., singleton - which you shouldn’t, but it happens), the change tracker accumulates entities over time, consuming increasingly more memory. AsNoTracking() on individual queries doesn’t help if other queries are still tracking.
Fix: Always register DbContext as Scoped (one per HTTP request). The change tracker gets disposed at the end of each request.
// Correct - Scoped lifetime (default with AddDbContext)builder.Services.AddDbContext<MovieDbContext>(options => options.UseNpgsql(connectionString));Key Takeaways
- Tracking queries (default) store entity snapshots in the
ChangeTrackerfor automatic change detection onSaveChanges(). Use them for CRUD operations where you modify data. - No-tracking queries (
AsNoTracking()) skip the change tracker entirely, reducing memory usage by ~50% and improving query speed by ~2x for large result sets. Use them for all read-only endpoints. - AsNoTrackingWithIdentityResolution is the middle ground - no tracking but with deduplication of shared related entities. Use it with
.Include()when related data is shared across parents. - Projections (
Selectinto anonymous types or DTOs) are never tracked, making them the best option for list endpoints. - For most Web APIs, set
NoTrackingas the default and useAsTracking()explicitly in update endpoints.
What is the difference between tracking and no-tracking queries in EF Core?
Tracking queries store entity snapshots in EF Core's ChangeTracker so modifications are automatically detected and persisted on SaveChanges(). No-tracking queries skip the ChangeTracker entirely, returning lightweight entity instances that are faster to materialize and use less memory. Use tracking for updates and deletes; use no-tracking for read-only operations.
When should I use AsNoTracking in EF Core?
Use AsNoTracking() whenever you're reading data that you won't modify. This includes list endpoints, detail endpoints for display, search and filter endpoints, reports, and any query where you don't call SaveChanges(). In a typical Web API, 70-80% of endpoints are read-only and should use AsNoTracking.
What is AsNoTrackingWithIdentityResolution and when should I use it?
AsNoTrackingWithIdentityResolution combines no-tracking with identity resolution. Use it when you're loading related entities with Include() and those related entities are shared across multiple parent entities (e.g., 50 movies sharing 5 directors). It prevents duplicate related objects in memory while still skipping change tracking.
Does AsNoTracking improve performance in EF Core?
Yes. AsNoTracking reduces memory allocation by approximately 50% and improves query execution time by roughly 2x for large datasets. The improvement comes from skipping the ChangeTracker's snapshot creation and identity resolution overhead. For small result sets (under 100 rows), the difference is minimal.
Can I change the default tracking behavior for all queries in EF Core?
Yes. Call UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking) when configuring DbContextOptions. This makes all queries no-tracking by default. You can then use AsTracking() on specific queries that need change detection for updates.
What happens if I try to save changes on a no-tracking entity?
Nothing - the changes are silently ignored. EF Core doesn't know the entity exists because it's not in the ChangeTracker. No error is thrown, no exception is raised. The SaveChanges() call completes successfully but generates zero SQL statements. To fix this, either use a tracking query or explicitly call db.Update(entity) to attach it.
Should I use AsNoTracking in a Web API?
Yes, for all read-only endpoints. Most Web API endpoints return data for display or API consumption without modifying it. Using AsNoTracking on these endpoints reduces memory usage and improves response times. Only use tracking queries for endpoints that update, create, or delete data.
How does the EF Core change tracker affect memory usage?
The ChangeTracker stores a snapshot of every tracked entity's original property values, plus internal data structures for identity resolution and navigation fix-up. For 10,000 entities, this can consume over 25 MB of additional memory compared to no-tracking. In long-running operations, use ChangeTracker.Clear() between batches to release tracked entities.
Troubleshooting Common Issues
“My update endpoint doesn’t persist changes after switching to NoTracking default.”
You need to add AsTracking() to queries in update endpoints. When the default is NoTracking, all queries - including FindAsync - return untracked entities.
“I’m getting duplicate related entities in my API response.”
You’re using AsNoTracking() with .Include() on shared related data. Switch to AsNoTrackingWithIdentityResolution() or use a projection with Select to shape the response.
“Memory keeps growing in my background job that processes large datasets.”
The ChangeTracker is accumulating entities across loop iterations. Add db.ChangeTracker.Clear() after each SaveChangesAsync() call to release tracked entities.
“I see ‘Cycles are not allowed in no-tracking queries’ error.”
AsNoTrackingWithIdentityResolution() doesn’t support circular Include paths. Restructure your query to avoid the cycle, or switch to a projection.
Summary
We covered how EF Core’s change tracker works, the three tracking modes (AsTracking, AsNoTracking, AsNoTrackingWithIdentityResolution), their performance implications with real benchmarks, and practical guidelines for choosing the right mode in your Web API endpoints. The recommended approach for most APIs is to set NoTracking as the default and explicitly opt in to tracking only for update operations.
The complete source code for this article - including the benchmark project and sample endpoints - is available on GitHub.
If you found this helpful, share it with your colleagues - and if there’s a topic you’d like to see covered next, drop a comment and let me know.
Happy Coding :)



What's your Feedback?
Do let me know your thoughts around this article.