Analyzer rule
Spec: LC012 - Optimize Bulk Delete with ExecuteDelete
EF Core LINQ performance analyzer and Roslyn analyzer for catching query issues at compile time.
Spec: LC012 - Optimize Bulk Delete with ExecuteDelete
Goal
Suggest using ExecuteDelete() instead of RemoveRange() for bulk deletions.
The Problem
RemoveRange() requires you to fetch all entities into memory before marking them for deletion. For large sets of data, this is very slow. ExecuteDelete() (available in EF Core 7+) performs a direct SQL DELETE on the database without loading any data into memory.
Example Violation
var oldLogs = db.Logs.Where(l => l.Date < oneYearAgo);
db.Logs.RemoveRange(oldLogs);
The Fix
Use ExecuteDelete().
// Fast: Direct SQL execution
db.Logs.Where(l => l.Date < oneYearAgo).ExecuteDelete();
Analyzer Logic
ID: LC012
Category: Performance
Severity: Warning
When it fires
LC012 is conservative. It reports only when:
- the project exposes an EF Core
ExecuteDelete()extension from the realMicrosoft.EntityFrameworkCorenamespace (so project-local or lookalike helpers do not enable the rule), - the single
RemoveRange(...)argument is still query-shaped (IQueryable<T>/DbSet<T>), and - no relevant
SaveChanges()/SaveChangesAsync()follows later in the same executable body. A later save is relevant unless it provably cannot commit these removals: a save on a provably different context instance — both receivers resolve, through single-assignment alias chains, to two different freshly-created (new) locals — and a save in a mutually exclusiveif/elseorswitchbranch (with nogoto case/goto defaultin the switch) do not suppress the report.
When it stays quiet (non-goals)
- Materialized lists or arrays (e.g.
...ToList()) and tracked entity collections — the fetch already happened, so there is nothing to optimize away. - Mixed or multiple
RemoveRange(query, entity)paramsarguments — no singleExecuteDelete()replacement preserves that call shape. - Custom or lookalike
ExecuteDelete()helpers outside the EF Core namespace, and EF Core versions whereExecuteDelete()is unavailable. - Calls followed by
SaveChanges()/SaveChangesAsync()later in the same executable body on the same or a possibly-aliasing context — replacing deferred tracked deletion with immediateExecuteDelete()would change unit-of-work timing. Aliases resolve through single-assignment chains (var db2 = db1;saves throughdb2still suppress); context parameters, fields, factory results, and reassigned locals can never be proven distinct and conservatively suppress. When theDbSetarrives as a parameter/local its owning context is unknowable, so any later save suppresses; aRemoveRangein atrywith a save in acatchalso stays quiet, because the try may throw after the removals were registered and the catch-side save would still commit them; aswitchcontaininggoto case/goto defaultkeeps suppressing because sections can flow into each other.
Code Fix
The fixer rewrites context.RemoveRange(query) to a direct bulk delete and prepends a warning comment (// Warning: ExecuteDelete bypasses change tracking and cascades.):
| Context | Rewrite |
|---|---|
| Synchronous method / lambda / local function | query.ExecuteDelete(); |
Async method / async lambda / async local function (with ExecuteDeleteAsync available) |
await query.ExecuteDeleteAsync(); |
Async context where no ExecuteDeleteAsync overload exists |
No fix offered |
The async branch matters: emitting a synchronous ExecuteDelete() inside an async method would inject a blocking, sync-over-async database call — the exact smell LC008 flags. The fixer therefore prefers the awaited ExecuteDeleteAsync() overload when the nearest enclosing function is async, and declines entirely rather than introduce a blocking call when only the synchronous overload is available. “Nearest enclosing function” is deliberate: a synchronous local function nested inside an async method still receives the synchronous rewrite, because await would be illegal there.
The fixer shares the analyzer’s later-save guardrails: it still declines when a following SaveChanges() / SaveChangesAsync() may commit the pending removals, but it remains available when that save is provably irrelevant because it sits in a mutually exclusive branch or resolves to a different freshly-created context local. In the different-context case, the fixer also proves the query source belongs to the RemoveRange receiver before it dismisses the later save; arbitrary helper-produced and multi-source queries stay manual.
Safety contract
ExecuteDelete() / ExecuteDeleteAsync() issue a single SQL DELETE and bypass EF Core change tracking, client-side cascades, save interceptors, and SavingChanges events. They also execute immediately rather than deferring to the next SaveChanges(), which changes unit-of-work timing. The analyzer and fixer both decline the later-SaveChanges shape for this reason, but they cannot see a SaveChanges() that lives in a different method. Apply the optimization only after confirming change tracking, cascades, interceptors, and deferred-save semantics are not required for the deleted rows.