EF Core: Tracking
Reading Time: 3 minutes
Problem
EF Core tracking is easy to misunderstand, and that often leads to one of two issues:
- read queries are slower than necessary because every entity is tracked
- updates fail or behave unexpectedly because tracked and untracked entities are mixed incorrectly
Typical symptoms include:
- high memory usage on read-heavy endpoints
- duplicate entity instances in results with confusing reference behavior
- accidental updates when entities were only meant to be read
- hard-to-debug “another instance with the same key is already being tracked” errors
The root issue is not choosing the right tracking mode for the query purpose.
Solution
Use tracking intentionally based on the use case.
Tracking(default): use when you will modify entities and callSaveChanges.AsNoTracking(): use for read-only queries where you do not need change detection.AsNoTrackingWithIdentityResolution(): use for read-only queries when the same entity may appear multiple times and you want one shared instance per key in the result graph.NoTrackingglobally: set default behavior for read-heavy apps, then opt in to tracking only where needed.
A practical baseline for APIs is:
- default to no-tracking for queries that return DTOs or read models
- use tracking only in command/update paths
Description
1) Tracking (default)
Tracking means EF Core stores queried entities in the change tracker. If you mutate a property and call SaveChangesAsync, EF generates UPDATE statements.
var product = await db.Products
.FirstAsync(p => p.Id == id); // tracked by default
product.Price = 49.99m;
await db.SaveChangesAsync();
Use this when your intent is to modify persisted state.
2) AsNoTracking
AsNoTracking() skips change tracker bookkeeping and is usually faster for pure reads.
var products = await db.Products
.Where(p => p.IsActive)
.AsNoTracking()
.Select(p => new ProductListItemDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price
})
.ToListAsync();
Use this for list pages, dashboards, search endpoints, and reporting.
3) AsNoTrackingWithIdentityResolution
AsNoTrackingWithIdentityResolution() is still read-only, but it resolves repeated entity keys to a single object instance within the query result.
var orders = await db.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.AsNoTrackingWithIdentityResolution()
.ToListAsync();
Why this matters:
- avoids multiple in-memory instances for the same related entity key
- keeps object graph references more consistent in complex read models
- still avoids full change tracking overhead
4) NoTracking as a default mode
You can set no-tracking as the context default for read-heavy workloads:
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString);
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});
Then explicitly opt in where updates are required:
var product = await db.Products
.AsTracking()
.FirstAsync(p => p.Id == id);
product.Stock -= quantity;
await db.SaveChangesAsync();
Quick decision guide
- Need to update entity data: use tracking.
- Read-only and simple: use
AsNoTracking(). - Read-only with repeated entity references in includes/graphs: use
AsNoTrackingWithIdentityResolution().
Summary
EF Core tracking is a performance and correctness choice, not just a default behavior.
- Tracking is best for updates.
AsNoTracking()is best for high-throughput read paths.AsNoTrackingWithIdentityResolution()is best for read-only graph queries that need consistent in-memory identity.
If you treat tracking mode as part of your query design, you get better latency, lower memory usage, and fewer change-tracker surprises.
References
- Microsoft Docs: Tracking vs. No-Tracking Queries
- Microsoft Docs: QueryTrackingBehavior Enum
- Microsoft Docs: Efficient Querying
- Microsoft Docs: Change Tracking in EF Core