Understanding AsTracking vs AsNoTracking in EF Core

Why Tracking Behavior Matters

Every time Entity Framework Core materializes entities from a query, it decides whether to track those entities in the DbContext's change tracker. Tracking is what lets EF Core detect changes and generate UPDATE/DELETE statements when you call SaveChangesAsync(). That capability isn't free — it costs memory (snapshotting original values, maintaining an identity map) and CPU cycles. In many read-only code paths, that cost buys you nothing.

The Default Behavior

By default, any LINQ query against a DbSet<T> is tracked:

 

public async Task<List<Order>> GetPendingOrdersAsync(string customerId){

    return await _context.Orders
        .Where(o => o.Status == OrderStatus.Pending && o.CustomerId == customerId)
        .ToListAsync();
}

This is implicitly tracked. EF Core snapshots every Order entity returned, in case one needs updating later via SaveChangesAsync(). If this method is part of a "fetch, mutate, save" workflow, tracking is doing useful work.

When AsNoTracking() Is the Better Choice

Not every query needs tracking. Read-only lookups — checking a permission, rendering a list, or validating a condition — don't need change tracking at all:

// Read-only check — no need to track

var isAuthorized = await _context.Permissions
    .AsNoTracking()
    .AnyAsync(p => p.UserId == userId && p.Resource == resource);

AsNoTracking() skips snapshotting entirely, which:

  • Reduces memory usage (no identity map entries, no original-value snapshots).
  • Speeds up query materialization, especially for larger result sets.
  • Avoids "phantom updates" — a subtle bug where an entity is loaded, mutated somewhere down the call stack, and unexpectedly persisted on the next SaveChangesAsync().

When to Use AsTracking() Explicitly

If your DbContext is configured with a default of QueryTrackingBehavior.NoTracking (e.g., via UseQueryTrackingBehavior), you can flip individual queries back to tracked mode with .AsTracking():

 

var order = await _context.Orders

    .AsTracking()
    .FirstOrDefaultAsync(o => o.Id == orderId);

order.Status = OrderStatus.Shipped;
await _context.SaveChangesAsync();

Without AsTracking() here, the change to Status would silently be lost — EF Core wouldn't know the entity changed because it never took a baseline snapshot.

A Practical Rule of Thumb

For services that mix read-heavy queries with write-heavy updates:

  1. Default the context to NoTracking at the DbContextOptionsBuilder level for maximum read performance.
  2. Explicitly call .AsTracking() on any query whose results will be mutated and saved later in the same unit of work.
  3. Keep "fetch → mutate → save" workflows tracked.
  4. Keep authorization/validation queries as AsNoTracking(), since they only inform a decision and are never saved.

Takeaway

Tracking isn't free, and it isn't always necessary. Auditing your EF Core queries — separating "I need to save this later" from "I just need to read this" — is a low-risk, high-value optimization, especially in systems where a shared DbContext handles both read-heavy validation logic and write-heavy processing logic.

Comments

Be the first to post a comment

Post a comment