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

Sample code · Analyzer source

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 provable DbContext receiver inside one executable root.
  • Separate context instances do not report; saving db1 and db2 is 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 if statements can still report because both branches may execute in one call. Mutually exclusive if/else, else if, switch sections, and switch-expression result arms stay quiet. A save in an if branch that ends in return or throw never runs before a save after that if, 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 a finally block still reports when the earlier save is in that try or catch, and so does a later save after a catch that swallows the branch’s throw without 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 as catch { 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 constant when (false) catch is never entered, and when (true) counts as no filter). LC039 does not follow goto and 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 as throw 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 or when guard can still report with a later arm because guards may run before matching continues.
  • try versus catch saves stay quiet because the catch save is a retry/compensation path after the try save failed. finally saves still report because the finally block runs after the try path.
  • Transaction boundaries are recognised only when the invocation is on EF Core’s DatabaseFacade or 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();
}