Analyzer rule
LC024: GroupBy with Non-Translatable Projection
LC024 flags EF Core GroupBy queries that project values SQL cannot translate, which causes runtime errors or accidental client-side grouping.
- Default severity
- Warning
- Code fix
- Manual only
- Category
- Performance
Change the severity in .editorconfig:
[*.cs]
dotnet_diagnostic.LC024.severity = error
LC024: GroupBy with Non-Translatable Projection
In Plain Terms
Imagine a teacher asks “How many students are in each class?” That’s easy — just count the names on the list. But if the teacher says “For each class, tell me every single thing every student had for lunch,” the teacher has to go ask every student individually. The counting is quick; the lunch survey is not.
What it flags
Flags IQueryable.GroupBy(...) pipelines that project values EF Core cannot translate cleanly to SQL, which usually triggers runtime failures or accidental client-side grouping.
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
Move the projection before the grouping only when it stays translatable, aggregate on the server, or materialize intentionally before performing complex grouping in memory.
LC024 is intentionally manual-only. The safe rewrite depends on whether the caller wants a server aggregate, a pre-grouped projection, or explicit client-side grouping after materialization.
Samples
See samples/LinqContraband.Sample/Samples/LC024_GroupByNonTranslatable/ for a focused example.
The crime
var query = db.Orders
.GroupBy(o => o.CustomerId)
.Select(g => new
{
g.Key,
Items = g.ToList()
});
Other risky shapes include calling local helpers over g.Key, using client-only string comparison overloads in the grouped projection, constructing objects from g directly, invoking local helpers named like aggregates with g, or nesting non-aggregate group access inside another projection object.
A better shape
var query = db.Orders
.GroupBy(o => o.CustomerId)
.Select(g => new
{
g.Key,
Count = g.Count(),
Total = g.Sum(o => o.Total)
});
LC024 applies to fluent GroupBy(...).Select(...), fluent GroupBy(..., (key, group) => ...) result selectors, and query-syntax group ... by ... into g select ... projections. It stays quiet for aggregate-only projections (Key, Count, LongCount, Sum, Average, Min, Max, Any, All) and for LINQ-to-Objects grouping where the source is already IEnumerable<T>.
EF Core 8 and later
EF Core 8 and 9 translate more of the group than older versions: element accessors (g.First(), g.OrderByDescending(f => f.Version).FirstOrDefault(), g.FirstOrDefault(p), Last after an OrderBy, Single, ElementAt), a filtered sub-sequence (g.Where(p)), and the group’s rows (g.ToList(), g.ToArray()) all produce SQL (checked with ToQueryString() on SQLite). When the project references EF Core 8 or later and a relational provider (not Cosmos), LC024 stays quiet on those shapes, as long as their predicates and selectors call no methods. The “latest row per group” query GroupBy(f => f.CorrelationId).Select(g => g.OrderByDescending(f => f.Version).First()) is the common case. With EF Core 7 or older, or when the EF Core version cannot be read, they still report. A local helper over the group (Pick(g)), a method inside the chain’s lambdas, passing g to a constructor, and g.Last() with no OrderBy in the chain (EF Core rejects it) still report on every version.
Translatable aggregate chains
EF Core 9 translates aggregates that filter or project the group before collapsing it to a scalar, so these stay quiet:
g.Any() // EXISTS / COUNT(*) > 0
g.Where(o => o.Amount > 0).Count() // COUNT over a filtered set
g.Select(o => o.Amount).Sum() // SUM(Amount) — same as g.Sum(o => o.Amount)
g.Distinct().Count() // COUNT(DISTINCT ...)
The exemption follows an aggregate whose receiver chain roots at the grouping parameter through translatable operators (Where, Select, OrderBy/OrderByDescending/ThenBy/ThenByDescending, Distinct), as well as the direct forms g.Count(), g.Sum(...), Enumerable.Count(g), Enumerable.Sum(g, ...).
Before EF Core 8, a chain that terminates in a non-aggregate is still reported, because it returns a sub-sequence or materializes per group rather than collapsing to a scalar: a bare g.Where(p), a materializer g.Select(s).ToList(), and an element accessor g.OrderBy(s).First() all remain crimes.
The exemption is deliberately conservative about the chain’s Where/Select lambda bodies: it covers only invocation-free predicates and selectors (member access, comparisons, arithmetic). Any method call inside a predicate or selector keeps the chain reported — a local function, a user-defined method (g.Select(o => Scale(o.Amount)).Sum()), or a non-translatable BCL overload (o.Name.Equals(s, StringComparison.OrdinalIgnoreCase), Regex.IsMatch(...)) — because the rule does not assume translatability it cannot prove from the expression shape alone. The single-argument System.Convert conversions (Convert.ToInt32, Convert.ToBoolean and the like) are the exception, because EF Core’s relational providers translate them to SQL casts.
A method applied to an aggregate’s result, such as Convert.ToBoolean(g.Min(c => Convert.ToInt32(c.ReadOnly))) or Math.Round(g.Average(c => c.Amount), 2), works on a scalar rather than the group’s elements, so LC024 does not report it. Whether that outer method translates is LC001’s concern.