← Back to all posts

EF Core: Tracking

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.

  1. Tracking (default): use when you will modify entities and call SaveChanges.
  2. AsNoTracking(): use for read-only queries where you do not need change detection.
  3. 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.
  4. NoTracking globally: 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