Use AsNoTracking for read-only EF Core queries
Add AsNoTracking to read-only queries to skip change-tracker snapshots without changing the generated SQL.
21 Apr 2026, 04:43 UTC

Use AsNoTracking for read-only query paths
You are building an API endpoint that returns orders for a dashboard. The code fetches rows, maps them to DTOs, and returns JSON. Nothing in that request updates the database. By default, EF Core still tracks every materialized entity: it stores an original-value snapshot for each property and maintains an identity map so SaveChanges can detect changes later. That bookkeeping costs allocations and CPU even though the context is disposed immediately after the response.
Add AsNoTracking() to read-only query paths. The SQL sent to the database does not change, but EF Core skips snapshotting and identity-map bookkeeping, returning fresh entity instances instead.
What change tracking normally does
When EF Core materializes a tracked entity, it places the instance in the context's identity map and records current property values as a snapshot. Later, SaveChanges compares current values against those snapshots to build UPDATE statements. For a read-only list, that work is wasted.
AsNoTracking() tells EF Core not to register the results in the change tracker. Each row becomes an independent object with no snapshot and no identity-map entry.
Worked example
This configuration assumes a standard EF Core DbContext and entity classes. Run it in your application service or controller; it requires only normal read access to the database.
public class Order
{
public int Id { get; set; }
public string Status { get; set; }
public List<OrderLine> Lines { get; set; }
}
public class OrderLine
{
public int Id { get; set; }
public int OrderId { get; set; }
public decimal Amount { get; set; }
}
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Order> Orders { get; set; }
public DbSet<OrderLine> OrderLines { get; set; }
}
The query below loads open orders with their lines. Because the results are only serialized to the response, tracking is unnecessary.
var openOrders = await context.Orders
.AsNoTracking()
.Where(o => o.Status == "Open")
.Include(o => o.Lines)
.ToListAsync(cancellationToken);
If you project to a DTO instead, tracking is equally unnecessary:
var summaries = await context.Orders
.AsNoTracking()
.Where(o => o.Status == "Open")
.Select(o => new OrderSummaryDto { Id = o.Id, Status = o.Status })
.ToListAsync(cancellationToken);
Verify the behavior
Check that no entries are tracked immediately after materialization:
var orders = await context.Orders.AsNoTracking().ToListAsync(cancellationToken);
int tracked = context.ChangeTracker.Entries().Count();
// Expected: 0
Confirm the SQL is identical with and without AsNoTracking() by enabling logging:
optionsBuilder.LogTo(Console.WriteLine);
The gain is client-side only: fewer allocations and less CPU in the application process. The database still executes the same plan and returns the same rows.
Limits and common mistakes
Lazy loading does not work
Lazy-loading proxies require tracking. A no-tracking query cannot populate navigation properties on demand. Eager-load with Include or project to a DTO.
Duplicate rows produce distinct instances
If the same entity appears multiple times in the result set, EF Core creates separate instances with no reference fixup.
Split queries can duplicate parents
Combining AsNoTracking() with AsSplitQuery() can duplicate parent rows unless identity resolution is enabled. AsNoTrackingWithIdentityResolution() is available in EF Core 9 and later; confirm against your target version before relying on it.
Do not save no-tracking instances
Calling SaveChanges after a no-tracking query will not persist changes to those instances. Passing a detached instance to Update() or Attach() marks every property as modified, which can overwrite concurrent changes.
It does not fix N+1 queries
AsNoTracking() is not caching and does not reduce database round trips. It only avoids change-tracker overhead in the application.
Practical verification checklist
- Build a small console app against a local database.
- Run a tracked query and a no-tracking query over the same payload.
- Assert
context.ChangeTracker.Entries().Count() == 0after the no-tracking materialization. - Use
LogToto confirm the generated SQL is identical. - Optionally compare allocations or elapsed time with BenchmarkDotNet or a stopwatch.
No rollback is needed because this pattern is read-only and does not modify database state.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.