Analyzer rule
LC038: Excessive Eager Loading
EF Core LINQ performance analyzer and Roslyn analyzer for catching query issues at compile time.
LC038: Excessive Eager Loading
What it flags
Flags EF Core query chains that use too many Include(...) / ThenInclude(...) calls on the same provable query root.
By default the threshold is 4 include steps. Teams can raise or lower it with:
dotnet_code_quality.LC038.include_threshold = 4
Invalid, missing, or non-positive values fall back to the default threshold.
Why it matters
Large eager-loading graphs often over-fetch related data, create wide joined result shapes, duplicate rows before materialization, and make query behaviour harder to reason about. Even when AsSplitQuery() avoids cartesian blow-ups, the query can still load more data than the caller needs.
LC038 is intentionally advisory. Sometimes a large Include graph is exactly the right shape for a screen or export. The diagnostic is a review prompt, not proof of a bug.
The crime
var customers = db.Customers
.Include(c => c.Address)
.ThenInclude(a => a.Country)
.ThenInclude(c => c.Region)
.ThenInclude(r => r.Continent)
.ToList();
Better shapes
Project the exact result when the caller only needs a DTO or scalar values:
var customers = db.Customers.Select(c => new CustomerSummary
{
Id = c.Id,
Country = c.Address.Country.Name,
OpenOrderCount = c.Orders.Count(o => o.IsOpen)
});
Split unrelated work into separate queries when different parts of the graph are used independently:
var customer = await db.Customers
.Include(c => c.Address)
.SingleAsync(c => c.Id == id);
var recentOrders = await db.Orders
.Where(o => o.CustomerId == id)
.OrderByDescending(o => o.CreatedAt)
.Take(20)
.ToListAsync();
Keep the Include graph when the caller genuinely needs the full aggregate and the cost is acceptable. In that case, consider AsSplitQuery(), query tags, pagination, and explicit suppressions so future readers know the large load was intentional.
What it counts
LC038 counts EF Core Include and ThenInclude calls in one chain once the chain is rooted in a provable EF query:
DbSet<T>properties and fields.DbContext.Set<T>().- Transparent query shapers before the Include chain, such as
Where, ordering,Skip,Take,AsNoTracking,AsNoTrackingWithIdentityResolution,AsTracking,AsSplitQuery,AsSingleQuery, andTagWith.
It does not guess through arbitrary helper methods or projected shapes.
What it does not flag
LC038 does not flag non-EF Include lookalikes, dynamic helper APIs, or query shapes where the EF root cannot be proven. It also does not decide whether the Include graph is wrong; the correct action depends on the caller, cardinality, and provider behaviour.
Relationship to LC006
LC006 looks for sibling collection Includes that can cause cartesian explosion. LC038 is broader and lower severity: it flags a high Include count even when the risk is simply over-fetching or review complexity.
Manual review checklist
- Does the caller use every included navigation?
- Would a projection be clearer and cheaper?
- Are unrelated branches better loaded as separate queries?
- Does
AsSplitQuery()help, or does it only hide the fact that too much data is loaded? - Is a suppression warranted because the full aggregate is intentionally loaded?