Analyzer rule
LC020: Avoid StringComparison Overloads in Query Expressions
LC020 flags Contains, StartsWith and EndsWith with a StringComparison argument in EF Core queries, which providers often cannot translate to SQL.
- Default severity
- Warning
- Code fix
- Available
- Category
- Performance
Change the severity in .editorconfig:
[*.cs]
dotnet_diagnostic.LC020.severity = error
LC020: Avoid StringComparison Overloads in Query Expressions
In Plain Terms
Imagine asking a robot to find all red blocks in a giant bin. But you give the robot a very complicated rule: “Find blocks that are red, but only if they are the exact shade of ‘Sunset Crimson’ from a specific 1990s crayon box.” The robot gets confused and just hands you the entire bin to sort yourself.
Goal
Detect string.Contains(...), StartsWith(...), and EndsWith(...) overloads that pass StringComparison inside System.Linq.Queryable expression lambdas. These overloads often cannot be translated by EF Core providers, or they translate with provider-specific semantics that do not match the requested .NET comparison.
The Problem
EF Core’s SQL translation logic is optimized for simple string predicate overloads such as Contains(string), StartsWith(string), and EndsWith(string). Passing StringComparison asks for .NET comparison semantics that many database providers cannot express directly. Depending on the provider and EF Core version, the query may fail translation or use semantics that are not what the application intended.
On the default EF Core relational providers — including SQL Server — these StringComparison overloads are not translated and throw InvalidOperationException at runtime (this is by design: SQL string comparison is collation-driven and EF will not guess). A few providers translate a subset: Npgsql maps StringComparison.OrdinalIgnoreCase to ILIKE, and Pomelo MySQL translates them only when EnableStringComparisonTranslations is opted in. The rule therefore stays provider-agnostic and flags the overload whenever the searched value is column-derived, which is the case the default provider rejects.
Example Violation
// Violation: likely to fail translation or depend on provider-specific behavior
var users = db.Users.Where(u => u.Name.Contains("admin", StringComparison.OrdinalIgnoreCase)).ToList();
The Fix
Use the simple overload when the configured database collation already gives the desired behavior. When the comparison semantics are intentional, prefer a database collation, normalized search column, or provider-specific function that keeps the behavior explicit and server-side.
// Correct when the database collation supplies the desired comparison semantics
var users = db.Users.Where(u => u.Name.Contains("admin")).ToList();
// Also correct: comparison happens on a captured local before query translation
var needle = "admin";
var users = db.Users.Where(u => needle.Contains("a", StringComparison.OrdinalIgnoreCase));
Analyzer Logic
ID: LC020
Category: Performance
Severity: Warning
Algorithm
- Target methods: inspect
string.Contains,string.StartsWith, andstring.EndsWith. - Overload check: require an argument or bound parameter of type
System.StringComparison. - Queryable context check: require an enclosing lambda passed to a
System.Linq.Queryableinvocation over anIQueryablesource. - Parameter-dependency check: require either the string receiver or a method argument to depend on a query lambda parameter —
u.Name.Contains("admin", ...)(column receiver),"admin".Contains(u.Name, ...)(column argument), or a nested collection predicate likeu.Orders.Any(o => o.Number.Contains(...)). The searched value is column-derived in each case, so the overload cannot translate. Nested local enumerable predicates, captured locals, constants, and other client-side strings are ignored.
Exceptions
- Calls on in-memory strings or
IEnumerable. - Queries that provably run on LINQ to Objects: a chain that starts at
AsQueryable()over an array or concrete collection, or atnew EnumerableQuery<T>(...).AsQueryable()over anIEnumerable<T>still reports, because the sequence may be aDbSetat runtime. - Calls on captured locals or constants inside a query predicate.
- Calls inside nested local enumerable predicates that do not depend on the query parameter.
- Calls inside custom
IQueryablehelpers that take delegate predicates instead ofQueryableexpression lambdas.
Test Cases
Violations
db.Users.Where(x => x.Name.Contains("abc", StringComparison.OrdinalIgnoreCase));
db.Users.Any(x => x.Email.StartsWith("test", StringComparison.CurrentCulture));
db.Users.Where(x => x.Name.EndsWith(".org", StringComparison.OrdinalIgnoreCase));
db.Users.Where(x => x.Orders.Any(o => o.Number.Contains("rush", StringComparison.OrdinalIgnoreCase)));
db.Users.Where(u => "admin".Contains(u.Name, StringComparison.OrdinalIgnoreCase)); // column flows through the argument
Valid
db.Users.Where(x => x.Name.Contains("abc"));
"some string".Contains("abc", StringComparison.OrdinalIgnoreCase); // Not in IQueryable context
var search = "abc";
db.Users.Where(x => search.Contains("a", StringComparison.OrdinalIgnoreCase)); // Not query-parameter dependent
var tags = new List<string> { "admin" };
db.Users.Where(x => tags.Any(tag => tag.Contains("a", StringComparison.OrdinalIgnoreCase))); // Local predicate
Shipped Behavior
LC020 reports query-parameter-dependent Contains, StartsWith, and EndsWith overloads that use StringComparison inside System.Linq.Queryable expression lambdas. The fixer removes the semantically bound StringComparison argument for straightforward calls, preserving the provider-side string predicate shape. It intentionally does not rewrite to ToLower, ToUpper, or provider-specific collation APIs because those choices are database- and domain-specific. Without the argument, the database collation decides case sensitivity: SQLite and PostgreSQL compare case-sensitively by default, so an OrdinalIgnoreCase match can stop matching, while SQL Server’s usual collation ignores case. When the removed comparison ignores case or is not a constant, the fix is titled “Remove StringComparison argument (database collation decides case sensitivity)” so the change is visible before it is applied; for a case-insensitive match on a case-sensitive database, use EF.Functions.ILike, EF.Functions.Like or a case-insensitive column collation instead.