Analyzer rule
LC039: Nested SaveChanges
LC039 flags repeated SaveChanges calls on the same DbContext in one method, which add round trips and can leave partial writes. Save once.
- Default severity
- Info
- Code fix
- Manual only
- Category
- Reliability
Change the severity in .editorconfig:
[*.cs]
dotnet_diagnostic.LC039.severity = error
LC039: Nested SaveChanges
In Plain Terms
Imagine mailing each page of your homework in a separate envelope instead of sending one finished packet.
Goal
Detect repeated SaveChanges() / SaveChangesAsync() calls on the same context in one method scope.
The Problem
Multiple saves in one executable root often mean unnecessary round-trips, fragmented transactions, or partial writes that should have been batched.
Example Violation
db.SaveChanges();
db.SaveChanges();
Safer Shape
Prefer one unit of work: make all tracked changes first, then save once.
user.Name = name;
order.Status = OrderStatus.Paid;
db.SaveChanges();
If two saves are deliberately separated because the first write must be flushed before the second step, make that boundary visible with an EF Core transaction or split the work into separate executable roots.
using var transaction = db.Database.BeginTransaction();
db.SaveChanges();
AuditAfterFirstCommit();
db.SaveChanges();
transaction.Commit();
The transaction is not a performance trick; it documents that the two saves are part of a deliberate transactional sequence rather than an accidental batching miss.
Analyzer Logic
ID: LC039
Category: Reliability
Severity: Info
Notes
The rule suppresses obvious EF Core transaction-boundary cases, repeated saves inside the same explicit transaction using block, repeated saves inside a C# 8+ using/await using local declaration of an EF Core transaction, mutually exclusive if/else branches, mutually exclusive switch sections and switch-expression result arms, and a try block versus a catch clause (a catch save is a compensating/retry save, not a batchable repeat — but a finally save still counts because it always runs), then reports on a per-context basis within the same executable root.
Only EF Core transaction APIs on DatabaseFacade or IDbContextTransaction-style receivers count as boundaries. Unrelated helper methods named Commit, Rollback, or similar do not suppress the diagnostic. A using declaration of an unrelated disposable (for example a MemoryStream) does not suppress the diagnostic, and a transaction using declaration introduced after the first save does not retroactively cover saves that preceded it.
Rule Boundary
- LC039 is scoped to repeated
SaveChanges()/SaveChangesAsync()calls on the same provableDbContextreceiver inside one executable root. - Separate context instances do not report; saving
db1anddb2is treated as two different units of work. - Nested local functions, lambdas, and other executable roots are analysed independently. A save in the outer method and a save in an inner local function are not treated as one repeated-save sequence.
- Independent
ifstatements can still report because both branches may execute in one call. Mutually exclusiveif/else,else if,switchsections, and switch-expression result arms stay quiet. A save in anifbranch that ends inreturnorthrownever runs before a save after thatif, so the later save is compared with the nearest earlier save that can precede it (insert-and-return, then update, as in Moonglade’s request counter). A later save in afinallyblock still reports when the earlier save is in thattryorcatch, and so does a later save after acatchthat swallows the branch’sthrowwithout returning or rethrowing. Catches are matched in order by the thrown type; a filtered catch, or one whose type is unrelated to the thrown type, is not treated as swallowing it. A catch that returns or rethrows on every path, such ascatch { if (retry) return; else throw; }, does not swallow it. A transaction boundary only separates two saves when it can run between them, so one inside an early-exit branch does not. Where control flow is uncertain LC039 stays quiet: a throw counts as caught only when every catch that could receive it, filtered or not, resumes (a constantwhen (false)catch is never entered, andwhen (true)counts as no filter). LC039 does not followgotoand stays quiet on goto-joined paths. A throw or exit that ends before the earlier save in its branch runs without that save, so it does not count against the early exit; one that contains the save, such asthrow Create(db.SaveChanges()), still does. An earlier save in a branch exclusive with the later save is passed over, so a save before both still pairs with it. A save in a switch-expression pattern orwhenguard can still report with a later arm because guards may run before matching continues. tryversuscatchsaves stay quiet because the catch save is a retry/compensation path after the try save failed.finallysaves still report because the finally block runs after the try path.- Transaction boundaries are recognised only when the invocation is on EF Core’s
DatabaseFacadeor an EF Core transaction type. Lookalike methods on application services are intentionally ignored. - This rule has no code fix. Whether to batch, split the method, introduce an explicit transaction, or keep the repeated save depends on the business invariant protected by the earlier commit.
Boundary Examples
Independent branches can both run, so the second save reports:
if (saveUser)
{
db.SaveChanges();
}
if (saveAudit)
{
db.SaveChanges();
}
Mutually exclusive branches stay quiet:
if (saveUser)
{
db.SaveChanges();
}
else
{
db.SaveChanges();
}
A retry/compensation save is allowed:
try
{
db.SaveChanges();
}
catch
{
db.SaveChanges();
}
A finally save always runs, so it still reports:
try
{
db.SaveChanges();
}
finally
{
db.SaveChanges();
}