Analyzer rule
Spec: LC044 - AsNoTracking query mutated then SaveChanges — silent data loss
EF Core LINQ performance analyzer and Roslyn analyzer for catching query issues at compile time.
Spec: LC044 - AsNoTracking query mutated then SaveChanges — silent data loss
Goal
Detect the chain AsNoTracking origin → property mutation → SaveChanges on the same context when no re-attach (Update / Attach / Entry(entity).State = Modified | Added) intervenes. In this pattern EF Core silently persists nothing — no exception, no log — and callers typically spend hours debugging why their update “didn’t stick”.
The Problem
AsNoTracking() tells EF Core not to track the entity in the change tracker. A subsequent property mutation has no effect on the DbContext state, and SaveChanges returns 0. Because there is no diagnostic at runtime, silent data loss is extremely hard to notice until production traffic discovers missing updates.
Example Violation
// Silent data loss: `SaveChanges` persists nothing.
var user = db.Users.AsNoTracking().FirstOrDefault(u => u.Id == id);
user.Name = "New Name";
db.SaveChanges();
Fixes
Either remove AsNoTracking so the entity is tracked from origin:
var user = db.Users.FirstOrDefault(u => u.Id == id);
user.Name = "New Name";
db.SaveChanges();
…or re-attach before saving:
var user = db.Users.AsNoTracking().FirstOrDefault(u => u.Id == id);
user.Name = "New Name";
db.Users.Update(user); // or db.Attach(user); or db.Entry(user).State = EntityState.Modified;
db.SaveChanges();
Analyzer Logic
ID: LC044
Category: Reliability
Severity: Warning
Algorithm
- Anchor: register on every
DbContext.SaveChanges/SaveChangesAsyncinvocation. - Context symbol: resolve the instance symbol of the SaveChanges call.
- Origin scan: in the same executable root, collect local declarations whose initializer is a materializer invocation (
First,FirstOrDefault,Single,ToList, async variants, …) and whose chain containsAsNoTracking. Collectforeachloops whose collection chain containsAsNoTracking. The chain scan honours the last tracking directive (eachAsTracking()/AsNoTracking()overwritesQueryTrackingBehavior):AsNoTracking().AsTracking()is tracked and does not report, whileAsTracking().AsNoTracking()is untracked and does. - Same-context gate: extract the DbSet-owner context symbol from the query chain and require
SymbolEqualityComparermatch against the SaveChanges context symbol. - Single-assignment gate: skip locals that are assigned more than once (ambiguous dataflow).
- Mutation scan: find the first property mutation whose target is an
IPropertyReferenceOperationinstance-referencing the local, positioned between the local’s declaration and the SaveChanges. A plain assignment (entity.Prop = …), a compound assignment (entity.Prop += …), and an increment/decrement (entity.Prop++) all count — each mutates the untracked entity and is silently lost. - Block reachability: the mutation must be in the same executable root as the SaveChanges and reachable from it. The mutation’s block and the SaveChanges’s block must be on the same branch (one is the same block, an ancestor, or a descendant of the other); blocks that are siblings under different
if/elsebranches orswitchsections do not reach each other. Explicitreturn/throwterminators between the mutation and the SaveChanges break reachability. - Earlier-save gate: if another SaveChanges on the same context already ran between the mutation and the current one, this anchor is not the silent-write site; skip.
- Re-attach gate: scan for
DbContext.Update/Attach/UpdateRange/AttachRange, theirDbSetcounterparts, orDbContext.Entry(entity).State = EntityState.Modified | Added— on the same context, with the entity as the argument. A re-attach after the mutation and before SaveChanges suppresses the diagnostic only when it is not invalidated by a reachableEntry(entity).State = DetachedorChangeTracker.Clear()before SaveChanges. A re-attach before the mutation also suppresses only when it dominates the mutation path and remains valid through SaveChanges; an optional branch re-attach is not enough. - Emit: report on the property reference (
entity.Prop) of the first matching mutation with the entity name and the property name.
False-Positive Disciplines
- Entity from a tracked query (no
.AsNoTracking()in the chain). .AsNoTracking()query followed only by reads, never by a property write.- Re-attach of any form present between the mutation and SaveChanges, or a guaranteed same-context re-attach before the mutation that is not invalidated by an explicit detach or tracker clear before SaveChanges.
- Conditional re-attach before the mutation is not treated as safe, because another path can still mutate and save the entity while it remains untracked.
- SaveChanges is on a different context instance than the query.
- Multiple reassignments to the same local (ambiguous dataflow).
- Mutation sits inside a branch that’s not an ancestor of the SaveChanges’s block (e.g., an
ifbranch that returns or throws early, or a sibling branch the SaveChanges cannot reach). - Entity arrives as a parameter from outside the method (v1 scope is intra-procedural; cross-method is future work).
- A different entity is mutated (symbol identity on
ILocalSymbolprevents cross-entity confusion). - Mutation is a collection member call (
list.Add(x)), not an entity property assignment.
Test Cases
Violations
var u = ctx.Users.AsNoTracking().FirstOrDefault();
u.Name = "x";
ctx.SaveChanges();
Valid
var u = ctx.Users.AsNoTracking().First();
ctx.Users.Update(u);
u.Name = "x";
ctx.SaveChanges();