Most of the queries in a typical app are reads. You fetch data, show it, and never change it. EF Core doesn't know that. By default it assumes every entity you load might be modified and saved back, so it keeps track of all of them. That tracking has a cost, and on read-only queries you're paying it for nothing.
AsNoTracking() turns it off for a query. The data comes back the same, minus the bookkeeping.
What Tracking Actually Does
When EF Core runs a normal query, it doesn't just hand you objects. It takes a snapshot of every entity and holds a reference to it in the change tracker. That's how SaveChanges() knows what changed — it compares the current state of each tracked entity against the snapshot it took when the entity was loaded.
That's what you want when you're updating. When you load a customer, change their email, and call SaveChanges(), the tracker is what figures out that one field changed and writes the UPDATE.
When you're loading a list of orders to render on a page, none of it matters. You're not saving anything, but EF Core still builds the snapshots and holds the references anyway, doing comparison work it will never use.
Where the Cost Shows Up
var orders = await _db.Orders
.Where(o => o.CreatedAt > lastWeek)
.ToListAsync();
This tracks every order it returns. Ten rows, fine. A few thousand rows on a reporting screen, and the tracking overhead becomes real — extra memory for the snapshots, extra CPU building them, and a change tracker now holding references to thousands of entities it'll never save.
Add AsNoTracking() and that goes away:
var orders = await _db.Orders
.AsNoTracking()
.Where(o => o.CreatedAt > lastWeek)
.ToListAsync();
Same SQL. Same data back. EF Core skips the snapshot and doesn't retain the entities. On large read queries the difference in memory and time is measurable, and it costs you one method call.
The Bug That Tracking Hides
There's a second reason this matters, and it's not about performance.
Because the change tracker holds references to everything it loads, it also returns the same instance if you query the same row twice in one context. Sometimes that's convenient. Sometimes it hides a problem — you edit a tracked entity somewhere, and a completely separate read query later hands back your modified version instead of what's actually in the database, because the tracker gave you the cached instance.
With AsNoTracking(), every query goes to the database and gives you a fresh object. For read paths, that's usually what you actually want.
When You Should Not Use It
AsNoTracking() is for reads. The moment you plan to change an entity and save it, you need tracking on, because that's the mechanism that detects the change.
var customer = await _db.Customers
.FirstAsync(c => c.Id == id);
customer.Email = newEmail;
await _db.SaveChanges(); // works because customer is tracked
Put AsNoTracking() on that query and SaveChanges() does nothing — EF Core isn't tracking the entity, so it has no idea anything changed. No error, no update, the email silently stays the same. The code looks correct, which is what makes it hard to find.
So the line is simple: reading and displaying, use AsNoTracking(). Loading something to modify and save, don't.
Doing It Everywhere Without Repeating Yourself
If most of your app is reads, adding AsNoTracking() to every query gets tedious and easy to forget. You can flip the default for a whole context:
optionsBuilder.UseSqlServer(connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
Now queries are no-tracking by default, and you opt back in on the queries that actually write, using .AsTracking(). For a read-heavy app this is often the better default — you stop paying for tracking everywhere and only turn it on where you need it.
Just make sure the team knows the default changed. Someone who assumes tracking is on will write the update code above and watch SaveChanges() quietly do nothing.
Our Take
At Qodors, when we profile a read-heavy .NET app that's using more memory than it should, tracking is a regular offender. It rarely shows up as a crash or an obvious error. It shows up as a service that holds more memory than the data justifies, or a request that's slower than the size of its result set explains.
It connects to something we've written about before — a query returning far more rows than it should because of the wrong IQueryable/IEnumerable return type. Fix that so you're loading the right rows, then add AsNoTracking() so you're not paying to track the read-only ones. The two together cover a large share of the EF Core performance problems we see.
It's default behavior that happens to be correct for writes and wasteful for reads, running in an app that mostly reads.
Quick Checklist
Add AsNoTracking() to queries that only read and display data
Leave tracking on for any query where you'll modify the entity and call SaveChanges()
For read-heavy apps, consider QueryTrackingBehavior.NoTracking as the context default and opt back in with .AsTracking()
If SaveChanges() silently does nothing, check whether the entity was loaded with AsNoTracking()
Watch for stale data from the change tracker returning a cached instance on read paths
Pair it with correct IQueryable return types so you're tracking fewer, and the right, rows
Tracking is on by default because EF Core can't tell a read from a write. On the queries where you're only reading, tell it. That's the whole optimization.
DotNet #CSharp #EFCore #EntityFramework #Performance #BackendDevelopment #SoftwareEngineering #DotNetCore #SQL #QodorsEdge
Written by the team at Qodors — we profile and fix slow, memory-hungry .NET apps. → www.qodors.com
Top comments (0)