.NET Programming With Me

Tracking vs. No-Tracking Queries in EF Core: When Each One Hurts You

If you have ever profiled an EF Core application under load and wondered why memory climbs steadily through a read-heavy endpoint, the change tracker is likely the culprit. Tracking queries are the EF Core default for good reasons, but applying them to every query in every scenario is one of the most common sources of unnecessary overhead in .NET API projects.

Tracking queries attach every loaded entity to the DbContext change tracker so EF Core can detect modifications and translate them to SQL on SaveChanges. No-tracking queries skip that bookkeeping entirely. Both exist because the right choice genuinely depends on what you are doing with the data.

By the end of this post you will understand what each mode costs, when the cost is worth paying, and how to update entities correctly in a no-tracking world.


1 A quick history first

Tracking queries have been the EF default since the original Entity Framework, designed for connected scenarios where you load an entity, modify it in the same unit of work, and call SaveChanges before the DbContext is disposed.

No-tracking queries (via AsNoTracking()) were added to address a growing class of read-only use cases, such as API list endpoints, reporting queries, and projections, where the caller never modifies the returned objects and paying the tracking cost is pure waste.

Both operate against the same SQL-generating LINQ pipeline; the difference is entirely in what happens after rows arrive from the database.


2 The same task, two ways

Suppose you are building a GET endpoint that returns a paginated list of products in a given category. Here is what each approach looks like for that exact query.

Tracking approach

// Default behavior: every returned Product is attached to the change tracker
var products = await _context.Products
    .Where(p => p.CategoryId == categoryId && p.IsActive)
    .OrderBy(p => p.Name)
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync(ct);

No-tracking approach

// AsNoTracking: entities are materialized but never registered with the change tracker
var products = await _context.Products
    .AsNoTracking()
    .Where(p => p.CategoryId == categoryId && p.IsActive)
    .OrderBy(p => p.Name)
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync(ct);

The SQL generated is identical. The difference is invisible in the query itself but measurable in memory and CPU once you scale to hundreds of rows or high request concurrency.


3 The real tradeoffs

1. Memory and change tracker overhead

For every tracked entity, EF Core allocates an EntityEntry, stores a snapshot of the original property values, and adds the entity to an internal identity map keyed by primary key. On a list endpoint returning 200 rows with 15 columns each, that overhead is measurable, not catastrophic, but it is completely avoidable if you have no intention of modifying those objects.

Does this matter in practice? Yes, under high concurrency. Because DbContext is scoped per request, tracking overhead does not accumulate across requests, but it does accumulate within a single request that loads large result sets. No-tracking cuts the per-entity overhead roughly in half in benchmarks from the EF Core team.

2. Identity resolution behavior

The tracking change tracker deduplicates: if two navigations both load the same row (same primary key), you get back the same object instance. No-tracking queries by default do not deduplicate, so you can end up with two separate Product instances representing the same database row after a join.

// No-tracking with identity resolution: faster than tracking, still deduplicates instances
var orders = await _context.Orders
    .AsNoTrackingWithIdentityResolution()
    .Include(o => o.Lines)
    .ThenInclude(l => l.Product)
    .ToListAsync(ct);

AsNoTrackingWithIdentityResolution() is the middle ground: no change-tracking bookkeeping, but a single pass of deduplication ensures you get one Product instance per primary key even if it appears in multiple order lines. Use it any time you load a graph with potentially shared child entities and care about reference equality.

3. Updating data after a no-tracking query

The most common mistake with no-tracking is trying to modify a returned entity and calling SaveChanges, expecting EF to detect the change. It will not; the entity is not in the change tracker, so nothing happens.

// Wrong: entity is not tracked, SaveChanges does nothing
var product = await _context.Products
    .AsNoTracking()
    .FirstAsync(p => p.Id == id, ct);

product.Price = 49.99m; // change not detected
await _context.SaveChangesAsync(ct); // no UPDATE issued

For disconnected update scenarios, use one of two patterns. If you have all the updated values, use ExecuteUpdateAsync to issue a targeted UPDATE without loading the row at all:

// Preferred for simple updates: no load required
await _context.Products
    .Where(p => p.Id == id)
    .ExecuteUpdateAsync(s =>
        s.SetProperty(p => p.Price, 49.99m)
         .SetProperty(p => p.UpdatedAt, DateTimeOffset.UtcNow),
        ct);

If you need to attach an already-materialized entity and mark specific properties as modified, use the Attach and Entry pattern:

// Attach pattern: re-registers the entity and marks only the changed columns
var product = new Product { Id = id, Price = 49.99m, UpdatedAt = DateTimeOffset.UtcNow };
_context.Attach(product);
_context.Entry(product).Property(p => p.Price).IsModified = true;
_context.Entry(product).Property(p => p.UpdatedAt).IsModified = true;
await _context.SaveChangesAsync(ct);

4. Projections supersede the tracking question

If you project directly to a DTO using Select, neither tracking mode matters for performance: EF Core does not materialize entities at all, so there is nothing to track. AsNoTracking() on a projection query has no measurable effect.

// AsNoTracking here is harmless but redundant — no entity is materialized
var dtos = await _context.Products
    .Where(p => p.CategoryId == categoryId)
    .Select(p => new ProductDto(p.Id, p.Name, p.Price))
    .ToListAsync(ct);

Project to DTOs whenever the caller needs only a subset of columns. It is faster than loading full entities in either tracking mode.


4 Which one should you choose?

The rule is simple: use tracking when you intend to modify the loaded entity within the same DbContext lifetime; use no-tracking everywhere else.

Use tracking queries when:

  • You load an entity, modify properties, and call SaveChanges in the same request.
  • You need EF Core to automatically detect which properties changed (without specifying them manually).
  • You are working with complex graphs where EF needs to track state across multiple related entities.
  • You are using the default lazy-loading proxies, which require tracked entities.

Use no-tracking queries when:

  • You are building read-only endpoints: GET lists, detail views, reports, feeds.
  • You are loading entities to pass to a view or serialize to JSON without modification.
  • You are running background jobs that read large batches of data for processing or export.
  • You are loading navigation-heavy graphs and using AsNoTrackingWithIdentityResolution to retain object deduplication.

A practical team convention: set AsNoTracking as the default on the DbContext for read services or query handlers, and opt in to tracking only in write handlers. This way the safe default is the fast one.

// Set no-tracking as the context default for a read-optimized DbContext
public class ReadDbContext : AppDbContext
{
    public ReadDbContext(
        DbContextOptions<AppDbContext> options,
        ITenantProvider tenantProvider)
        : base(options, tenantProvider)
    {
        ChangeTracker.QueryTrackingBehavior =
            QueryTrackingBehavior.NoTracking;
    }
}

5 Quick reference summary
Concern Tracking No-Tracking
Change detection on SaveChangesAutomaticNot supported; use ExecuteUpdateAsync or Attach
Memory per entityHigher (snapshot + EntityEntry)Lower (entity instance only)
Identity resolution (dedup)Automatic via identity mapDisabled by default; use AsNoTrackingWithIdentityResolution
Generated SQLIdenticalIdentical
Safe for read-only endpointsYes, but wastefulYes, and optimal
Required for lazy loadingYesNo
Projection queries (Select to DTO)No benefit either wayNo benefit either way

Final take

Tracking is the right default when you genuinely need it; no-tracking is the right default when you do not. The problem is that EF Core ships with tracking on, so you have to actively choose no-tracking for read-heavy endpoints, and most teams never do until a profiler shows them the cost.

Teams doing classic CRUD with load-modify-save in a single request should leave tracking on and not overthink it. Teams building read APIs, CQRS query sides, or any endpoint that never modifies returned objects should make AsNoTracking the default and opt in to tracking explicitly.

What's next?
  • Profile your current endpoints: Run a quick benchmark with and without AsNoTracking() on your heaviest list queries and measure the difference in allocations using BenchmarkDotNet or dotnet-counters.
  • Try ExecuteUpdateAsync: Replace any pattern where you load a single entity just to change one field with a direct ExecuteUpdateAsync call and observe the reduction in round trips.
  • Read about concurrency: The next post in this series covers how tracking interacts with concurrency tokens in PostgreSQL and how to handle DbUpdateConcurrencyException gracefully.
Got a question or a scenario I did not cover? Drop a comment below and I will reply.

No comments: