What tracking actually costs
When EF Core materialises an entity from a query, it does not just hand you the
object. It records that entity in the context's change tracker and keeps a
snapshot of its original property values, so that at SaveChanges it can
compare current values against the snapshot and generate UPDATE statements for
whatever differs.
That is the feature. You mutate objects, call save, and the right SQL appears
without you describing the change.
For a read it is pure cost, and it is threefold. Every entity is duplicated in
memory by its snapshot. Every entity is inserted into the tracker's internal
dictionaries. And SaveChanges, if it is ever called on that context, walks
every tracked entity comparing every property.
At ten entities none of this is measurable. At fifty thousand it is roughly
double the memory and a detection pass over half a million property comparisons.
var rows = await _db.Orders
.Where(o => o.CreatedAt >= from)
.ToListAsync(ct);
var rows = await _db.Orders
.AsNoTracking()
.Where(o => o.CreatedAt >= from)
.ToListAsync(ct);
The rule that follows is simple enough to apply without thinking: if you are
not going to save it, do not track it. Some teams set no-tracking as the
default for the context and opt in per query, which inverts the failure from
"slow reads everywhere" to "a save that silently does nothing", and that second
failure is louder and therefore better.
Projecting to a DTO with Select sidesteps the question entirely — anonymous
types and non-entity types are not tracked, and you fetch only the columns you
need rather than every column so you can discard most of them.
The same mechanism, the other symptom
The interesting half of this question is the follow-up, because it is the same
behaviour producing a bug rather than a slowdown.
var order = await _db.Orders.FirstAsync(o => o.Id == id, ct);
order.Status = order.Status.Trim();
await _db.SaveChangesAsync(ct);
Nobody called Update. Nobody intended to write. The entity was tracked, a
property changed, and SaveChanges did exactly what it promises — it saves
changes, and it does not know which of them you meant.
This is why the tracked/untracked decision is a correctness question as well as
a performance one. An entity you fetched to inspect, passed to a mapper, and let
a formatting helper touch is an entity you may have modified. AsNoTracking
makes that impossible: there is no snapshot, so there is nothing to detect and
nothing to write.
The identity map, which is why the row is one object
The tracker also serves as an identity map within a context. Query the same row
twice and you get the same instance back, not two.
var a = await _db.Orders.FirstAsync(o => o.Id == id, ct);
var b = await _db.Orders.FirstAsync(o => o.Id == id, ct);
ReferenceEquals(a, b);
That is often what you want — two parts of a request agreeing about one entity —
and it has a consequence people are surprised by. The second query still goes to
the database, but the tracker keeps the first instance's values, so a change
made by someone else in between is not reflected in the object you hold. The
data is fresh in the result set and stale in the entity.
With AsNoTracking, the same two queries produce two independent objects, each
reflecting what the database said at the time.
Context lifetime is part of the same subject
Everything above accumulates for as long as the context lives. Registered
scoped, which is the default in ASP.NET Core, the context lasts one request and
the tracker is discarded with it — fine.
Two arrangements break that. A context held as a singleton accumulates every
entity anyone loads for the life of the process, which is both a leak and a
correctness problem, and it is one of the shapes the captive-dependency bug
takes: inject a scoped context into a singleton and the singleton holds the
first request's instance forever.
And a long-running job that loops over batches inside one context grows without
bound even with tracking notionally justified. The conventional handling is a
fresh context per batch, obtained from a context factory rather than injected.
What to check when a read is slow
Confirm the query is untracked or projected. Confirm you are selecting the
columns you need rather than whole entities. Then look at what the query
actually sent, because tracking overhead and an N+1 from a lazy-loaded
navigation feel identical from the outside and only one of them is fixed by a
flag:
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);
Reading the generated SQL routinely is the habit that separates people who are
effective with an ORM from people at its mercy, and it answers most performance
questions in this area faster than any amount of reasoning about the abstraction.
The context is not a query API with a cache attached, it is a unit of work that
is watching everything you loaded. Tell it not to watch what you are only
reading.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).