← Open in the full interactive course (progress tracking, search & more)

Every entity a query returns comes with a hidden cost by default — EF Core remembers it, in case you're about to change it. Most of the time, for reads, that memory is pure waste.

Picture a product listing page — a hundred products, rendered read-only, nobody editing anything. You write the obvious query: context.Products.ToListAsync(). It works. But behind the scenes, EF Core just did more work than you asked for: for every one of those hundred products, it took a snapshot of every property value and started watching for changes — change tracking, the exact mechanism from the DbContext lesson that makes SaveChanges() so convenient. On a page that will never call SaveChanges() at all, that's pure overhead, paid on every single request.

In this lesson, you'll learn what the change tracker actually does per entity, how .AsNoTracking() skips that work for read-only queries, and the concrete rule for deciding which one a given query needs.

What Is It?

The Simple Explanation

By default, every entity a query returns is tracked — the DbContext keeps a copy of its original values and watches it for changes, so that a later SaveChanges() call knows exactly what to update. A no-tracking query skips all of that: EF Core still runs the SQL and hands you back fully-populated objects, it just doesn't bother remembering or watching them afterward.

The Technical Definition

Query tracking behavior is controlled per query with two extension methods: .AsNoTracking() and .AsTracking() (the default, and rarely written explicitly). It can also be set as the context-wide default via context.ChangeTracker.QueryTrackingBehavior, which individual queries can still override.

// Tracked (the default) — EF Core snapshots and watches these entities List<Product> tracked = await context.Products.ToListAsync(); // No-tracking — EF Core returns the data, keeps no snapshot, watches nothing List<Product> readOnly = await context.Products.AsNoTracking().ToListAsync();

Why Does It Exist?

The Problem — Tracking Is Only Useful If You Intend to Save Changes

Change tracking exists to answer one question later: "what did this entity change to, compared to when it was loaded?" That question only matters if you're planning to call SaveChanges(). A huge share of real-world queries never do — dashboards, listing pages, API responses that only read data and hand it straight to a client. For every one of those, tracking is pure cost with zero benefit: memory held for snapshots that will never be compared, and CPU time spent on bookkeeping for changes that will never happen.

The Solution — Let the Query Say Whether It Intends to Write

.AsNoTracking() lets you tell EF Core, per query, "I am only reading this — don't bother." EF Core skips the snapshot, skips the tracking-entry bookkeeping, and in many cases can materialize the resulting objects faster too, since it doesn't need to check whether an entity with the same key is already being tracked. For read-heavy applications — which is most applications, most of the time — this is a meaningful, low-effort performance win.

Big Picture

Tracked query

No-tracking query

How It Works

WHAT HAPPENS DIFFERENTLY, PER ENTITY, AT MATERIALIZATION TIME
Tracked query — for each row returned
No-tracking query — for each row returned

Simple Example

The exact same edit, attempted on a tracked entity versus a no-tracking one, has very different outcomes:

// Tracked — the edit is saved Product tracked = await context.Products.FirstAsync(p => p.Id == 1); tracked.Price = 29.99m; await context.SaveChangesAsync(); // UPDATE runs — change tracker saw the difference // No-tracking — the edit is silently lost Product readOnly = await context.Products.AsNoTracking().FirstAsync(p => p.Id == 1); readOnly.Price = 29.99m; await context.SaveChangesAsync(); // Nothing happens — this entity isn't tracked at all, // so SaveChanges() doesn't even know it exists.

This isn't a bug — it's the entire point of .AsNoTracking(). It tells EF Core, and anyone reading the code, "this data is for reading only." Modifying a no-tracking entity and expecting a save is the single most common mistake developers make when first introduced to this feature — covered in detail below.

Real-World Example

A typical Web API with two endpoints for the same entity shows exactly where each behavior belongs — a read-only listing endpoint, and an endpoint that updates a single product:

[ApiController] [Route("api/products")] public class ProductsController(ShopDbContext context) : ControllerBase { // Read-only — never saves anything. No-tracking is the correct choice. [HttpGet] public async Task<IActionResult> GetAll() { List<Product> products = await context.Products .AsNoTracking() .OrderBy(p => p.Name) .ToListAsync(); return Ok(products); } // Reads, then modifies, then saves. Tracking (the default) is required. [HttpPut("{id}/price")] public async Task<IActionResult> UpdatePrice(int id, [FromBody] decimal newPrice) { Product? product = await context.Products.FindAsync(id); // tracked by default if (product is null) return NotFound(); product.Price = newPrice; await context.SaveChangesAsync(); // works — this entity is tracked return NoContent(); } }

Same DbContext, same entity type — two different tracking needs, decided per query by whether that specific request will ever call SaveChanges() on the results.

Analogy

A Library Loan vs. A Photocopy

A tracked entity is like borrowing a book from a library: the library keeps a record of exactly which book you took and its exact condition when you took it, specifically so that when you bring it back, they can tell if anything changed. That bookkeeping is the whole point — it's how the library knows what to update in its own records.

A no-tracking entity is like being handed a photocopy of a page instead. You can read it, scribble on it, even set it on fire — the library keeps no record of it at all, because it was never checked out in the first place. Handing back a marked-up photocopy and expecting the library's records to update is exactly the mistake of editing a no-tracking entity and expecting SaveChanges() to notice.

Under the Hood

WHY NO-TRACKING QUERIES ARE ACTUALLY FASTER, NOT JUST "LESS WORK LATER"
1. Identity resolution is skipped
2. No snapshot allocation
3. AsNoTrackingWithIdentityResolution() — a middle ground

Common Confusion

1. "No-tracking means the query is read-only at the database level too"

.AsNoTracking() only affects what EF Core does with the entities after the query returns — it has nothing to do with permissions or what SQL is allowed to run. It doesn't stop you from writing a .Where(...) that filters data, and it certainly doesn't prevent a completely separate INSERT, UPDATE, or DELETE elsewhere in the same context. It only means: whatever this specific query returns won't be remembered or watched.

2. "Two identical no-tracking queries in the same context return the same object"

They don't — and this is the opposite behavior from tracked queries. Run the same no-tracking query for product #1 twice in the same context, and you get back two separate object instances with identical data, not the same instance. Identity resolution (returning the same instance for the same key) is a tracking feature; skip tracking, and you also skip that guarantee — unless you specifically use .AsNoTrackingWithIdentityResolution().

Common Mistakes

Mistake 1 — Editing a no-tracking entity and being confused when SaveChanges() does nothing

Loading with .AsNoTracking(), modifying a property, calling SaveChanges(), and expecting an UPDATE — since the entity was never registered with the change tracker, EF Core has no idea it exists, let alone that it changed. No exception, no error — the save simply does nothing for that entity. Use tracked queries (the default — just don't call .AsNoTracking()) for anything you intend to modify and save; if you already have a no-tracking instance you need to update, explicitly re-attach it with context.Update(entity) or context.Attach(entity) first.

Mistake 2 — Leaving every query tracked "by default" on a read-heavy endpoint

A dashboard or listing endpoint that queries hundreds of entities every request, never calling SaveChanges() on them, but never adding .AsNoTracking() either — every one of those requests pays full change-tracking overhead for nothing. Default to .AsNoTracking() for any query whose results are purely for reading/returning, and reserve tracked queries for the specific operations that actually modify and save data.

Mistake 3 — Assuming .AsNoTracking() disables lazy loading or navigation property population

Adding .AsNoTracking() and being surprised that .Include(...) still works, or that included navigation properties are still populated. .AsNoTracking() only changes what happens to tracking state after materialization — .Include(...) and query shaping behave exactly the same either way.

When Should I Use It?

SituationUse
Listing pages, dashboards, API responses that only return data.AsNoTracking()
Loading an entity you're about to modify and saveTracked (the default — no call needed)
A read-heavy service whose queries almost never modify resultsSet context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking context-wide, and use .AsTracking() explicitly on the few queries that need it
Same entity returned multiple times via relationships, read-only, but object identity still matters (e.g. deduplicating in a UI).AsNoTrackingWithIdentityResolution()
Rule of thumb: Ask one question per query: "will I call SaveChanges() on anything this query returns?" If no, add .AsNoTracking(). If yes, leave it tracked (the default). Don't guess — the question has a definite answer for almost every query you'll write.

Mental Model

Tracked = a borrowed book — the library remembers its condition, so it can spot what you changed.
No-tracking = a photocopy — nobody's watching it, and nobody expects it back.

Remember: tracking exists purely to make SaveChanges() work later — if there's no later save, tracking is a cost with no payoff.

Key Takeaway


Check Your Understanding

You've seen what tracking actually costs, and when skipping it pays off. Let's confirm the reasoning stuck.

1. A developer loads a product with .AsNoTracking(), changes its Price property, and calls SaveChangesAsync(). What happens?

Show answer

Correct: C

Why C is correct: A no-tracking entity is never added to the change tracker, so it has no snapshot to compare against and SaveChangesAsync() has no way of knowing it was ever loaded, let alone modified. The call succeeds — it just does nothing for that entity.

Why A is incorrect: This is exactly the mistaken assumption the lesson warns against — no-tracking absolutely does affect whether a later save picks up the change, because tracking is what makes SaveChanges() aware of the change at all.

Why B is incorrect: EF Core doesn't throw in this situation — there's no error at all, which is precisely what makes this mistake easy to miss during development.

Why D is incorrect: A no-tracking entity is a completely ordinary C# object — its properties can be set freely; the object just isn't watched by EF Core.

Reinforcement: No-tracking entities can be edited in C# just fine — the missing piece is that EF Core was never told to watch for that edit in the first place.

2. Why is .AsNoTracking() specifically appropriate for a GET endpoint that returns a list of products for display, but not for an endpoint that updates a product's stock count?

Show answer

Correct: B

Why B is correct: The deciding factor is whether SaveChanges() will run against the results. The listing endpoint only reads and returns data — tracking buys it nothing. The update endpoint needs to detect exactly what changed, which is the entire purpose of tracking — no-tracking there would silently break the update, just like in question 1.

Why A is incorrect: This isn't a general truth about HTTP verbs — the reasoning is specific to whether SaveChanges() runs, not the verb used.

Why C is incorrect: There's no such enforced convention — the choice is a deliberate performance/correctness decision per query, not an automatic rule tied to the HTTP method.

Why D is incorrect: Tracked entities serialize into JSON exactly the same as no-tracking ones — tracking state has nothing to do with serialization.

Reinforcement: The one question that decides tracking vs no-tracking is always the same: will this query's results be modified and saved afterward?

3. A dashboard query uses .Include(...) to load orders along with their customers, using .AsNoTracking(). What effect does .AsNoTracking() have on the .Include(...) behavior?

Show answer

Correct: B

Why B is correct: .AsNoTracking() only affects post-materialization behavior — whether the change tracker keeps a snapshot and watches the entity. It has no effect on query shaping: .Include(...) still generates the join and populates the navigation property exactly the same way.

Why A is incorrect: This describes a real but unrelated gotcha (forgetting .Include(...) entirely) — not what .AsNoTracking() itself does. Combining the two works fine and is a very common, recommended pattern for read-only queries with related data.

Why C is incorrect: .AsNoTracking() is a normal LINQ extension method affecting tracking behavior — it doesn't change how the query is translated to SQL or bypass the query pipeline.

Why D is incorrect: These two are commonly and safely combined — there's no conflict between eager loading and skipping change tracking.

Reinforcement: Keep the two concerns separate in your mind: .Include(...) controls what data comes back; .AsNoTracking() controls what EF Core does with it afterward.

You now know exactly when to skip change tracking for a real performance win, and when you can't afford to. Next up: what happens when saving needs to span more than one operation — transactions and concurrency.


dotnetmadeeasy.com — Learn C# and .NET, the right way.