Documentation
EF Core SaveChanges in Loop Analyzer
Use LinqContraband as an EF Core SaveChanges in loop analyzer for repeated SaveChanges calls, N+1 writes, sync saves in async methods, and batch-write review.
EF Core SaveChanges in Loop Analyzer
LinqContraband is an EF Core SaveChanges in loop analyzer for .NET projects that want repeated database writes to
show up during development and CI. It helps reviewers catch per-item commits, sync saves in async methods, and bulk
update loops before they turn one request into hundreds of database roundtrips.
Install the official analyzer package:
dotnet add package LinqContraband
Why SaveChanges Inside a Loop Hurts
SaveChanges and SaveChangesAsync are unit-of-work boundaries. Calling them once after a batch lets EF Core commit the
tracked changes together. Calling them inside a loop opens the door to one database write per item:
foreach (var user in users)
{
user.LastLogin = clock.UtcNow;
await db.SaveChangesAsync();
}
That shape is easy to miss in review because each save looks correct in isolation. At production row counts, it can mean many transactions, many roundtrips, slower request latency, and harder-to-reason-about partial progress.
The batched shape is usually clearer:
foreach (var user in users)
{
user.LastLogin = clock.UtcNow;
}
await db.SaveChangesAsync();
LinqContraband Rules That Help
| Rule | What it detects | Review direction |
|---|---|---|
| LC010: SaveChanges inside loop | Direct SaveChanges or SaveChangesAsync calls inside for, foreach, await foreach, while, or do loops. |
Move the save outside the loop when one batched commit preserves the business rule. |
| LC032: use ExecuteUpdate for bulk updates | A provable tracked update loop followed by SaveChanges where a uniform scalar change can become ExecuteUpdate. |
Consider a set-based update when bypassing tracking, callbacks, interceptors, and deferred-save timing is acceptable. |
| LC012: use ExecuteDelete instead of RemoveRange | Query-shaped RemoveRange calls that can avoid loading rows before delete. |
Use ExecuteDelete only after checking cascade, interceptor, and unit-of-work timing requirements. |
| LC008: sync-over-async blocker | Synchronous SaveChanges inside async control flow when SaveChangesAsync is available. |
Use async EF Core APIs in async methods so request threads are not blocked. |
Safer Write Patterns
Batch ordinary tracked changes and commit once:
foreach (var user in users)
{
user.LastLogin = clock.UtcNow;
}
await db.SaveChangesAsync(cancellationToken);
Use ExecuteUpdateAsync for a uniform scalar change that does not need entity callbacks or tracked state:
await db.Users
.Where(user => user.IsActive)
.ExecuteUpdateAsync(setters => setters
.SetProperty(user => user.LastLogin, user => clock.UtcNow),
cancellationToken);
Keep per-item saves only when the boundary is deliberate:
foreach (var message in outboxMessages)
{
await PublishAsync(message, cancellationToken);
message.MarkPublished(clock.UtcNow);
// Deliberate durability boundary: each message records progress independently.
await db.SaveChangesAsync(cancellationToken);
}
In that case, document the reason with a suppression or code comment so the exception survives future review.
Review Checklist
- Does the loop need one transaction per item, or can the changes be committed once after the loop?
- Would batching change retry behaviour, progress durability, audit timing, or external side effects?
- Is a uniform scalar update better expressed with
ExecuteUpdate? - Is a query-shaped delete better expressed with
ExecuteDelete? - In async methods, is the save using
SaveChangesAsyncand passing the available cancellation token?
CI Severity Starter
Start with repeated saves as warnings, then promote them once the team has cleared intentional per-item commits:
[*.cs]
# Repeated writes and async save policy
dotnet_diagnostic.LC010.severity = warning
dotnet_diagnostic.LC008.severity = warning
# Set-based write opportunities
dotnet_diagnostic.LC032.severity = suggestion
dotnet_diagnostic.LC012.severity = suggestion
Use the EF Core query analyzer CI guide when these diagnostics should run on every pull request. Use the EF Core ExecuteUpdate analyzer guide when the loop can become a set-based update or delete.
Official Links
- Canonical repository: github.com/georgepwall1991/LinqContraband
- Official NuGet package: nuget.org/packages/LinqContraband
- Full rule catalog: georgepwall1991.github.io/LinqContraband/rule-catalog.html
- Safe install guidance: Official LinqContraband downloads and authenticity