Analyzer rule
LC031: Unbounded Query Materialization
EF Core LINQ performance analyzer and Roslyn analyzer for catching query issues at compile time.
LC031: Unbounded Query Materialization
What it flags
Flags materialization of an apparently unbounded query because loading an entire table or broad result set usually indicates missing filters, missing pagination, or an accidental scan.
Collection materializers include ToList(), ToArray(), ToDictionary(), ToHashSet(), ToLookup(), and the async EF variants where EF provides one.
Why it matters
LinqContraband reports this rule when the query shape suggests a risky or non-translatable pattern that is better made explicit before it reaches production.
Typical fix
Add a real row bound, usually Take, ordered Skip/Take pagination, keyset/cursor pagination, or a single-row terminal such as FirstOrDefault. A Where filter is often still useful, but it narrows the result set without capping it.
LC031 follows direct DbSet query chains, DbContext.Set<TEntity>() query chains, query-syntax expressions, and simple single-assignment local aliases:
var query = db.Users.Where(user => user.IsActive);
var users = query.ToList(); // LC031
var activeUsers =
(from user in db.Users
where user.IsActive
select user).ToList(); // LC031
It stays silent on bounded aliases and ambiguous reassigned locals rather than guessing which query shape reaches the materializer.
What does not count as a bound
These operators or query options do not prove a capped result set:
Where(...): narrows matching rows, but can still match every row.OrderBy(...): changes order only.Skip(...)without a laterTake(...): can still load every row after the skipped prefix.TakeLast(...)/SkipLast(...): EF Core cannot translate these as bounded server-side operations for normal relational queries.Chunk(size): thesizeargument bounds each returned chunk, not the total number of rows fetched. Do not treatChunkas pagination; putTakeor orderedSkip/Takebefore chunking if the source can be large.- Query options such as
AsNoTracking(),AsTracking(),AsSplitQuery(), orAsSingleQuery(): useful options, but not row-count bounds.
Add the limit before crossing to LINQ-to-Objects or materializing:
var page = await db.Users
.Where(user => user.IsActive)
.OrderBy(user => user.Id)
.Skip(pageIndex * pageSize)
.Take(pageSize)
.ToListAsync();
Intentional full scans
LC031 has no automatic fixer because the correct remediation is product-specific. A UI list usually needs pagination; an export may need a background job, streaming, batching, or a reviewed suppression; a maintenance path may need an explicit operational limit. The analyzer cannot safely choose between those designs.
When the full scan is intentional, keep the code explicit and document the reason close to the query, for example with a narrow suppression around an export or backfill path.
Samples
See samples/LinqContraband.Sample/Samples/LC031_UnboundedQueryMaterialization/ for a focused example.
The crime
var allOrders = await db.Orders.ToListAsync();
A better shape
var recentOrders = await db.Orders
.Where(o => o.CreatedAt >= cutoff)
.OrderByDescending(o => o.CreatedAt)
.Take(200)
.ToListAsync();