Analyzer rule
Spec: LC009 - Missing AsNoTracking in Read Path
EF Core LINQ performance analyzer and Roslyn analyzer for catching query issues at compile time.
Spec: LC009 - Missing AsNoTracking in Read Path
Goal
Suggest using AsNoTracking() for queries that only read data and do not modify entities.
The Problem
By default, EF Core tracks every entity it fetches so it can detect changes. This tracking process consumes CPU and memory. For read-only operations (like a search page or a dashboard), this overhead is wasted and slows down your application.
Example Violation
public List<User> GetActiveUsers()
{
// Fetches and tracks users, even if we only display them
return db.Users.Where(u => u.Active).ToList();
}
The Fix
Add .AsNoTracking() to the query.
public List<User> GetActiveUsers()
{
// Fast read-only query
return db.Users.AsNoTracking().Where(u => u.Active).ToList();
}
Analyzer Logic
ID: LC009
Category: Performance
Severity: Info
When it fires
LC009 reports when a read-only query is materialized (ToList/ToArray/First/Single/AsEnumerable, sync or async, and friends) over an EF source with no tracking opt-out. The EF source is recognized both as:
- a
DbSet<T>property (db.Users.ToList()), and - a
DbSet<T>returned from a method, most importantly the generic-repositorycontext.Set<T>()read path (db.Set<User>().ToList()).
When it stays quiet (non-goals)
- The query already opts a tracking mode in:
AsNoTracking(),AsNoTrackingWithIdentityResolution(), or an explicitAsTracking(). - The query contains a
Select(...)projection — a projection to a non-entity shape is not tracked anyway. - The enclosing method returns
IQueryable<T>(deferred execution — the caller owns the tracking decision). - The source is an
IQueryable<T>/DbSet<T>parameter or local (ambiguous origin — the caller may use it for writes). - A write is detected in the same executable body (
SaveChanges/SaveChangesAsync, orAdd/AddRange/Update/Remove/RemoveRangeon aDbSet/DbContext). - A property of the materialized result is mutated in the same body — on the result local (only when the materializer’s value is stored directly into a single-assignment local), a nested member rooted in that result (
user.Profile.DisplayName = name), aforeachiteration variable over it, or inline on the materializer (db.Users.First(...).Name = x, compound assignment and++/--included). A mutation implies the entity is on a write path even when theSaveChangeslives in a helper the analyzer cannot see, and suggestingAsNoTracking()would break that cross-method save. Mutating an unrelated object does not count: a DTO populated from entity values, a repointed local mutated while it held a different object, and indexer writes that replace a collection element (users[0] = new User()) all leave the rule firing.
Code Fix
The fixer inserts AsNoTracking() directly on the EF source it found, using the semantic type rather than syntax so it places the call correctly for both shapes:
db.Users.Where(...).ToList() -> db.Users.AsNoTracking().Where(...).ToList()
db.Set<User>().Where(...).ToList() -> db.Set<User>().AsNoTracking().Where(...).ToList()
(A purely syntactic walk could not tell the Set<T>() source invocation apart from a .Where(...) operator and would mis-place AsNoTracking() onto the DbContext.)
When AsNoTracking is not safe
AsNoTracking() is a behaviour change, not just a perf tweak — apply the fix only on genuinely read-only paths:
- Identity resolution. No-tracking queries do not de-duplicate entity instances. A query that
Includes a collection (or otherwise returns the same entity more than once) yields multiple distinct instances. UseAsNoTrackingWithIdentityResolution()when a single shared instance per entity matters. - Deferred / cross-method mutation. A local mutation of the materialized entity now suppresses the rule (see above), but if the entity is returned untouched and a caller mutates and saves it, that remains invisible —
AsNoTracking()would silently drop the change. The diagnostic isInfoprecisely because this cross-method case cannot be proven locally. - Re-attach / explicit state. If the entity is later
Attached or hasEntry(entity).Stateset for an update, it must be tracked — do not addAsNoTracking().