Analyzer rule
LC007: N+1 Database Execution Inside Loop
LC007 finds EF Core N+1 queries: Find, ToList, Count and other database calls that provably run once per loop iteration instead of once.
- Default severity
- Warning
- Code fix
- Available
- Category
- Performance
- Domain
- Execution & Async
Change the severity in .editorconfig:
[*.cs]
dotnet_diagnostic.LC007.severity = error
LC007: N+1 Database Execution Inside Loop
In Plain Terms
Imagine you need 10 eggs. You drive to the store, buy one egg, drive home. Drive back, buy one egg, drive home. You do this 10 times. You spend all day driving instead of just buying the carton at once.
Goal
Catch EF Core database execution that is provably performed once per loop iteration.
What LC007 Reports
1. Direct EF lookups inside loops
Find and FindAsync on DbSet<T> are reported when they execute inside for, foreach (including the deconstruction form foreach (var (a, b) in …)), await foreach, while, or do loops.
foreach (var id in ids)
{
var user = db.Users.Find(id);
}
2. Explicit loading inside loops
Reference(...).Load/LoadAsync and Collection(...).Load/LoadAsync are reported because they issue a separate database operation per entity.
foreach (var user in db.Users.ToList())
{
db.Entry(user).Collection(u => u.Orders).Load();
}
3. Query materialization, aggregates, and set-based executors on proven EF sources
LC007 reports materializers and executors such as ToList, Count, Any, ExecuteDelete, and ExecuteUpdate when the query origin is provably EF-backed.
foreach (var user in db.Users.ToList())
{
var orderCount = db.Entry(user).Collection(u => u.Orders).Query().Count();
}
while (queue.TryDequeue(out var userId))
{
db.Users.Where(u => u.Id == userId).ExecuteDelete();
}
The analyzer follows direct EF roots such as DbSet<T>, DbContext.Set<T>(), navigation Query() calls, and single-assignment query local hops when the origin stays provable.
Deferred AsEnumerable() boundaries before terminal execution still report when the upstream source is provably EF-backed.
When a query materializer is used as the source of an inner foreach, LC007 still reports if that inner loop sits inside another loop and the source is re-executed once per outer iteration.
4. Helper methods that run the query
The most common hidden N+1 is a loop that calls a helper in the same project which runs the query. LC007 reads the helper’s body and reports the call in the loop:
foreach (var order in orders)
{
var customer = await GetCustomerAsync(order.CustomerId, ct);
// LC007: 'GetCustomerAsync' runs 'FirstOrDefaultAsync' on every iteration of the loop
}
private Task<Customer?> GetCustomerAsync(int id, CancellationToken ct) =>
_db.Customers.FirstOrDefaultAsync(c => c.Id == id, ct);
This covers ordinary and static methods, extension methods and local functions whose source is in the project being analyzed, followed up to three calls deep (loop → A → B → C running the query). The helper’s execution must be one LC007 would report if it were written in the loop: Find, an explicit load, or a materializer or executor on a provably EF-backed source such as _db.Customers or db.Set<T>(). The loop exemptions below (batch, drain, polling, retry, Chunk, level-by-level loops) apply at the call site exactly as they do to a direct query, and a while, do or for loop whose condition runs a query through a helper (while (await HasPendingAsync())) counts as a drain loop.
LC007 stays quiet on a helper call when:
- the helper is overridable:
virtual,abstractor interface dispatch, unless the method or its type issealedorstatic, since an override might not query; - the helper exists only as metadata, or its source belongs to another project;
- the query sits in a lambda or local function the helper only declares, such as a cache factory
_cache.GetOrCreateAsync(key, _ => db.Customers.FirstAsync(...)); - the helper is memoized: it calls a cache API (
TryGetValue,ContainsKey,GetOrAdd,GetOrCreate, any*Cache*type), assigns with??=or reads with??a field or property, or returns early from anifthat reads a field or property of its own type before the query; - the helper’s query is inside a loop of its own (reported there once, or exempt there as a batch loop);
- the helper only builds and returns an
IQueryable. Executing that result in the loop (Children(id).Count()) is not traced back into the helper; - the query source is a parameter (
IQueryable<T>,DbSet<T>or aList<T>), which LC007 cannot prove is EF-backed.
The code fix is not offered for helper calls.
What LC007 Intentionally Ignores
- Plain LINQ-to-Objects or
AsQueryable()sources AsEnumerable()aggregates over already in-memory collections- Aggregates, lookups, or filters over already materialized
List<T>/array/local DTO collections inside loops - Ambiguous
IQueryableprovenance through parameters, fields, properties, or multi-assignment locals - Query construction inside loops when no execution method is invoked
Reference(...)andCollection(...)access withoutLoad,LoadAsync, orQuery()execution- Invocations nested inside lambdas or local functions declared in the loop body (a call to such a local function from the loop is a helper call, see above)
- Loop-source materialization that happens once before iteration, such as the
db.Users.ToList()part of aforeach while,doandforloops that run until the database says they are done, as long as the loop condition does not walk an item source of its own (see below)- Load-once guards that run the query at most once (see below)
Batch, polling and retry loops
A loop that queries until the data runs out issues one query per batch, not one per item, so LC007 stays quiet on it. This covers keyset and paged batching, batched deletes, drain loops, BackgroundService polling and catch-guarded retries:
var lastId = 0;
while (true)
{
var batch = await db.Orders.Where(o => o.Id > lastId).OrderBy(o => o.Id).Take(500).ToListAsync(ct);
if (batch.Count == 0) break; // the batch decides when the loop ends
lastId = batch[^1].Id;
Process(batch);
}
while (await db.Outbox.Take(1000).ExecuteDeleteAsync(ct) > 0) { }
while (!ct.IsCancellationRequested)
{
Dispatch(await db.Outbox.Where(m => m.SentAt == null).ToListAsync(ct));
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
A while, do or for loop counts as one of these when:
- the query result decides when the loop stops: the execution sits in the loop condition, or its result (or a local computed from it, such as
hasMore = batch.Count == size) is read by the condition or by anifthat breaks out of the loop or returns; - the loop condition itself queries the database (
while (await db.Jobs.AnyAsync(...))); - the loop body waits with
Task.DelayorThread.Sleep, or the condition waits onPeriodicTimer.WaitForNextTickAsync; or - the execution sits in a
trywith acatch, and thetrybreaks out of the loop or returns after it succeeds; or - the query pages by a counter the loop advances: its
Skip(...)reads the counter (Skip(page * size), or anoffsetthe body increases) and itsTake(...)reads more than one row; or - the loop is a batch drain loop (
whileordoonly): another query in the same iteration is bounded byTake(n)withnother than 0 or 1, and its result ends the loop as in the first point, such as the row count anExecuteDeletereturns or the length, count or emptiness of the materialized batch. Every query in that iteration, including the delete or update that works on the batch, then runs once per batch.
var found = int.MaxValue;
while (found >= options.BatchSize)
{
var query = db.PersistedGrants.Where(g => g.Expiration < now).OrderBy(g => g.Expiration);
var expired = await query.Take(options.BatchSize).AsNoTracking().ToArrayAsync(ct);
found = expired.Length; // a short batch ends the loop
if (found > 0)
{
await query.Where(g => g.Expiration <= expired[^1].Expiration).ExecuteDeleteAsync(ct); // quiet: one per batch
}
}
The exemption only applies when the loop condition reads nothing but constants, integer counters (including an integer setting such as _options.BatchSize, read through fields and properties that are not collections), bool flags, cancellation, database executions and values derived from the query result. A condition that walks items of its own, such as queue.TryDequeue(out var id), reader.Read() or i < ids.Length, still reports, even when the loop also breaks on the result. Queries in a foreach report, except over Chunk(...) as below, and so does a query inside a batch loop that sits in an outer per-item loop.
A foreach over ids.Chunk(n) is the usual way to keep an IN list under the database’s parameter limit, and it stays quiet when the query reads the whole chunk: batch.Contains(...), the chunk passed as an argument, or a local built from it such as batch.ToHashSet():
foreach (var batch in seriesIds.Chunk(500))
{
var series = await db.Series.Where(s => batch.Contains(s.Id)).ToListAsync(ct); // one query per chunk
}
A query that reads one element of the chunk (batch[0], batch.First()) or runs inside a nested foreach (var id in batch) still reports.
Level-by-level hierarchy walks
Walking a tree one level per query is the usual way to avoid a query per node, so LC007 stays quiet on it too:
var frontier = new List<Guid> { rootId };
while (frontier.Count > 0)
{
var next = await db.Folders
.Where(f => frontier.Contains(f.ParentId)) // the whole level in one query
.Select(f => f.Id)
.ToListAsync(ct);
frontier = new List<Guid>();
foreach (var id in next)
if (visited.Add(id)) frontier.Add(id); // the next level comes from the result
}
This applies when the loop condition reads a collection that the loop refills from the query result (Add, AddRange, Enqueue, Push or UnionWith with values from it, or an assignment), and the query uses that collection only as a whole: frontier.Contains(...), or frontier passed as an argument, directly or through locals the query is built from. A worklist that takes one item at a time still reports, because it runs one query per node:
while (pending.Count > 0)
{
var id = pending.Dequeue();
var children = db.Folders.Where(f => f.ParentId == id).Select(f => f.Id).ToList(); // LC007
foreach (var child in children) pending.Enqueue(child);
}
Load-once guards
A query that only fills a local the first time the loop needs it runs once, not once per item:
CategoryDefinition definition = null;
foreach (var item in items)
{
if (definition == null)
definition = await db.Definitions.FirstAsync(d => d.Key == key, ct); // quiet: runs once
// or: definition ??= await db.Definitions.FirstAsync(d => d.Key == key, ct);
Apply(definition, item);
}
This applies when the query’s result is assigned to a local declared before the loop, the assignment is ??= or sits directly in the then-branch of an if whose condition is x == null or x is null (optionally combined with &&), and nothing else writes that local inside the loop or from a lambda or local function. The same holds for a guarded helper call. A local declared inside the loop (or inside an outer loop that repeats the guard), a local reset inside the loop, a guard on a different local, an || or != null condition, and an assignment in the else branch still report. The assignment must leave the local non-null, or the guard stays open: FirstOrDefault, SingleOrDefault, Find and their async forms (which return null when no row matches), a helper whose result is nullable, marked [return: MaybeNull], or carries no nullable annotations, a value passed through an as or user-defined conversion, a Select projection or in-memory source in code without nullable annotations (its element can be null), a guard using a user-defined == or conversion, an assignment inside a try whose catch lets the loop continue, and a local also reachable through a ref alias or passed by ref to a call still report.
Fixer Behavior
LC007 offers a fixer only for conservative, analyzer-proven explicit-loading cases.
- It rewrites unconditional strongly-typed
Reference(...).Load/LoadAsyncandCollection(...).Load/LoadAsyncinsideforeachorawait foreachloops to eager loading withInclude(...). - It updates the loop source query and removes the per-item load statement.
- It does not offer a fix for helper-method calls, string-based navigation access,
Find, aggregates,Query().Count(), filtered navigation queries, conditional loads, or control-flow-heavy loops.
Example Fix
// Before
foreach (var user in db.Users.ToList())
{
db.Entry(user).Collection(u => u.Orders).Load();
Console.WriteLine(user.Id);
}
// After
foreach (var user in db.Users.Include(u => u.Orders).ToList())
{
Console.WriteLine(user.Id);
}