Analyzer rule
LC057: Min, Max or Average throws on an empty query
LC057 flags EF Core Min, Max and Average over a non-nullable value, which throw 'Sequence contains no elements' when no row matches.
- Default severity
- Warning
- Code fix
- Available
- Category
- Reliability
Change the severity in .editorconfig:
[*.cs]
dotnet_diagnostic.LC057.severity = error
LC057: Min, Max or Average throws on an empty query
In Plain Terms
You asked for the most expensive item in an empty shop. There is no answer, and instead of saying “none” the cashier panics.
Goal
Detect Min, Max and Average (and MinAsync, MaxAsync, AverageAsync) over a non-nullable value on an EF Core query, which throw when the query matches no rows.
The Problem
SQL MIN, MAX and AVG return NULL for an empty set. When the result type is a non-nullable value type such as decimal, int or DateTime, EF Core has nowhere to put the NULL and throws InvalidOperationException: “Sequence contains no elements” (the in-memory provider reports “Nullable object must have a value”). The query works in development, where the table has data, and fails in production for the first customer, category or date range with no rows. Sum does not have this problem: EF Core returns 0.
// Violation: throws when the category has no products.
var highest = await db.Products
.Where(p => p.CategoryId == categoryId)
.MaxAsync(p => p.Price, ct);
See the EF Core issues dotnet/efcore#18955, #20589 and #17988.
The Fix
Cast the selected value to its nullable type. The query then returns null for an empty set, and the code decides what that means:
var highest = await db.Products
.Where(p => p.CategoryId == categoryId)
.MaxAsync(p => (decimal?)p.Price, ct) ?? 0m;
Checking Any() first also works, but costs a second round trip and can race with concurrent deletes.
Analyzer Logic
ID: LC057
Category: Reliability
Severity: Warning
- Find
Queryable.Min,Queryable.MaxandQueryable.Average, and EF Core’sMinAsync,MaxAsyncandAverageAsync, with or without a selector. - Require the aggregated value, the selector’s result or the element type of the selector-less overloads, to be a non-nullable value type.
- Require the source to be an EF Core query: a
DbSet,DbContext.Set<T>()orDatabase.SqlQuery<T>(), followed through LINQ and EF Core query operators, locals whose every assignment is such a query (includingq = q.Where(...)), and non-overridable helper methods in the project whose every return is such a query, such as a repository’sIQueryable<Product> Query() => _db.Products;or anIQueryable<T>extension that composes over its argument.
When it stays quiet (non-goals)
- The value is already nullable:
Max(p => (decimal?)p.Price),Max(p => p.Discount)whereDiscountisdecimal?, or aSelectthat projects a nullable value. - The value is a reference type such as
string: the result isnullfor an empty set. Sum,Countand other aggregates.- The source is not provably EF Core: an
IQueryable<T>parameter, field or property, an interface or virtual repository method, or an in-memoryAsQueryable(). A query passed in as a parameter is often checked by the caller. - The same method checks the query first: an earlier
Any,AnyAsync,Count,CountAsync,LongCountorLongCountAsynccall on a query that starts from the same local or expression, as inif (!q.Any()) return;,q.Any() ? q.Max(...) : 0orvar count = await q.CountAsync(); if (count == 0) return;. The check is not inspected further: the rule prefers a missed report to a false one. - Aggregates inside a
GroupByprojection (g.Max(...)): a group always has at least one row. - Aggregates inside another query’s lambda, which EF Core translates as part of that query.
- LINQ to Objects aggregates over materialized lists.
Code Fix
Casts the aggregated value to its nullable type: Max(p => p.Price) becomes Max(p => (decimal?)p.Price), and a selector-less Max() gets a casting selector, Max(x => (decimal?)x). When the result is used as the non-nullable value it was before, the fix appends ?? default (in parentheses inside a larger expression) so the surrounding code keeps its type, and an empty query now yields 0, default(DateTime) and so on instead of an exception. Replace default with the value that suits your code. When the result is already stored as the nullable type (decimal? max = ...), only the cast is added.
The fixer compiles the result and offers nothing when the rewrite would add an error or change the result’s type: a selector that is not a lambda, an explicit generic argument such as Max<Product, decimal>(...), or an async aggregate whose task is not awaited where it is created.
Test Cases
Violations
db.Products.Max(p => p.Price);
await db.Products.Where(p => p.CategoryId == id).AverageAsync(p => p.Rating, ct);
db.Products.Select(p => p.Created).Min();
repository.Query().Max(p => p.Price);
Valid
db.Products.Max(p => (decimal?)p.Price);
db.Products.Sum(p => p.Price);
if (query.Any()) { var max = query.Max(p => p.Price); }
db.Products.GroupBy(p => p.CategoryId).Select(g => g.Max(p => p.Price));