Analyzer rule
LC061: Compiled query is not cached
LC061 flags EF Core EF.CompileQuery and EF.CompileAsyncQuery calls that compile the query on every call instead of once in a static readonly field.
- Default severity
- Warning
- Code fix
- Available
- Category
- Performance
Change the severity in .editorconfig:
[*.cs]
dotnet_diagnostic.LC061.severity = error
LC061: Compiled query is not cached
In Plain Terms
You had a key cut so you would not have to pick the lock every morning, then threw the key away each night and had a new one cut the next day.
Goal
Detect EF.CompileQuery and EF.CompileAsyncQuery calls whose delegate is created again every time the code runs, instead of once.
The Problem
A compiled query saves EF Core the work of looking the query up in its query cache, but only when the delegate is created once and reused. That is why the EF Core documentation on compiled queries stores it in a private static readonly field. Calling EF.CompileQuery inside a method builds the expression tree and compiles the query on every call. That costs more than the ordinary LINQ query it replaced, which at least finds its compiled form in EF Core’s query cache after the first run.
// Violation: compiles the query on every call.
public Task<Blog?> Get(int id) =>
EF.CompileAsyncQuery((BlogContext c, int i) => c.Blogs.FirstOrDefault(b => b.Id == i))(_db, id);
The Fix
Compile once, into a static readonly field, and invoke the field:
private static readonly Func<BlogContext, int, Task<Blog?>> GetQuery =
EF.CompileAsyncQuery((BlogContext c, int i) => c.Blogs.FirstOrDefault(b => b.Id == i));
public Task<Blog?> Get(int id) => GetQuery(_db, id);
Analyzer Logic
ID: LC061
Category: Performance
Severity: Warning
Reports Microsoft.EntityFrameworkCore.EF.CompileQuery and EF.CompileAsyncQuery when the delegate is:
- Invoked straight away, in a method, instance constructor, instance field or property initializer, accessor, lambda or local function:
EF.CompileQuery(...)(db, id)orEF.CompileQuery(...).Invoke(db, id). This includes a delegate nested inside a cache factory, such ascache.GetOrAdd(key, _ => (c, id) => EF.CompileQuery(...)(c, id)): the cache keeps the inner delegate, which compiles on every call. - Assigned to a local whose every use invokes it:
var query = EF.CompileQuery(...); return query(db, id);. - Returned from an expression-bodied property or a
getaccessor, which compiles it on every read:static Func<...> ById => EF.CompileQuery(...);. - Returned from a private method or local function whose every call invokes the result straight away:
Build()(db, id). Calls are matched by symbol, so a call to another overload ofBuilddoes not count. A factory whose only callers are static field or property initializers or a static constructor runs once and stays quiet. - Stored in an instance field or property of
thisorbasefrom an ordinary method or accessor, without anifwhose whole condition guarantees that member is null in the branch holding the store (if (_query == null),if (_query is null && ready), or theelseofif (_query != null)orif (_query != null || other)). A!= nullguard,if (_query == null || refresh), theelseofif (_query != null && ready), a guard on another member, or a guard on a local that shares the member’s name still reports.
When it stays quiet (non-goals)
- Static field and static auto-property initializers, even when they invoke the compiled delegate straight away, and assignments to static members (including in a static constructor).
??=andif (_query == null) _query = ...lazy initialization.- A
Lazy<T>orAsyncLazy<T>stored straight into a field or property of this instance (unqualified,this.orbase.) or a static one, and kept there: a field or property initializer, a constructor,??=, a null guard, or a static member, judged the same way as a compiled query stored directly. An unguarded_lazy = new Lazy<...>(...)in an ordinary method replaces it on every call and reports, and so does a store on another object (holder.Query = new Lazy<...>(...)), whose lifetime is not known. Any other use may build it on every call and reports:.Value,.GetValueAsync(),.Task, a local, an argument or a return value. - Instance field and property initializers that store the delegate, and assignments in instance constructors. Whether the instance lives long enough to reuse the delegate (a singleton, or a scoped service built per request) is not known, so the rule does not guess.
- Lambdas passed as the add-value factory of a cache that runs it once per key and is kept outside the call, when the compile call sits directly in that lambda rather than in a delegate nested inside it. A cast or parentheses around the factory (
(Func<string, Blog>)(_ => ...)) and named arguments in any order (EnsureInitialized(valueFactory: ..., target: ref _query)) are read the same way. The cache (the receiver ofGetOrAdd,GetOrCreateorGetOrCreateAsync) must be a field, auto-property or parameter, such as an injectedIMemoryCache(a property with a getter body, such asCache => new(), may build a new cache on every read and reports); thereftarget ofEnsureInitializedorImmutableInterlocked.GetOrAddmust be a field or arefparameter. Any other cache reports, including a new instance and any local, even one copied from a field. The update factory ofAddOrUpdateruns every time the key exists, and a lambda passed as the cached value itself is what the cache keeps, so both still report. The caches are matched by symbol, so a project’s own method of the same name, which may run the factory on every call, still reports:ConcurrentDictionary<TKey, TValue>.GetOrAddandAddOrUpdate,ImmutableInterlocked.GetOrAdd,LazyInitializer.EnsureInitialized, theIMemoryCacheextensionsGetOrCreateandGetOrCreateAsync,HybridCache.GetOrCreateAsync,System.Lazy<T>, and theAsyncLazy<T>types of Microsoft.VisualStudio.Threading and Nito.AsyncEx. - Dictionary stores (
cache[key] = EF.CompileQuery(...)) and the delegate passed to another method. - Factory lambdas that return the delegate (
() => EF.CompileQuery(...)), and public, internal or protected methods that return it, because their callers decide how long it lives. A private factory is quiet as soon as one caller does anything other than invoke the result, such as initializing a static field. - A local that is stored, returned or passed on as well as invoked.
Code Fix
Moves the compile call into a private static readonly field declared as the first member of the type (after any compiled-query fields it already hoisted), typed with the delegate the call returns (keeping nullable annotations), and uses the field where the call was. Static field initializers run in source order, so a field at the top is set before any other static initializer runs, including one that reaches the member through another method:
// Before
public Task<Blog?> Get(int id) =>
EF.CompileAsyncQuery((BlogContext c, int i) => c.Blogs.FirstOrDefault(b => b.Id == i))(_db, id);
// After (the field is the first member of the class)
private static readonly Func<BlogContext, int, Task<Blog?>> GetQuery = EF.CompileAsyncQuery((BlogContext c, int i) => c.Blogs.FirstOrDefault(b => b.Id == i));
// ...
public Task<Blog?> Get(int id) => GetQuery(_db, id);
The field is named after the member (GetQuery, then GetQuery2 and so on when the name is taken). No fix is offered when the query lambda reads a local, a parameter of the method or the instance (a static field cannot see them; pass the value as a query parameter instead), when it reads a static member of the type (its order against the new field is not known), when the delegate type uses a method type parameter, when the member sits inside an #if, #elif or #else region opened in the type before it (the field at the top would stay active in configurations that leave the member out), when another partial declaration of the type has any static field or property initializer or static constructor (the order across parts is not defined), when the first member of the type has a preprocessor directive in front of it (such as #nullable enable, which the new field would sit outside of), when the member and the top of the type are in different #nullable contexts, or when the rewritten document would have more compiler errors than before.
Test Cases
Violations
return EF.CompileQuery((Ctx c, int i) => c.Blogs.First(b => b.Id == i))(_db, id);
var query = EF.CompileQuery((Ctx c) => c.Blogs.Count()); return query(_db);
public static Func<Ctx, int, Blog> ById => EF.CompileQuery((Ctx c, int i) => c.Blogs.First(b => b.Id == i));
return ((Func<Ctx, int, Blog>)EF.CompileQuery((Ctx c, int i) => c.Blogs.First(b => b.Id == i)))(_db, id);
return (EF.CompileQuery((Ctx c, int i) => c.Blogs.First(b => b.Id == i))!)(_db, id);
if (_byId is { }) _byId = EF.CompileQuery((Ctx c, int i) => c.Blogs.First(b => b.Id == i));
Valid
private static readonly Func<Ctx, int, Blog> ById = EF.CompileQuery((Ctx c, int i) => c.Blogs.First(b => b.Id == i));
private static Func<Ctx, int, Blog> _byId; // _byId ??= EF.CompileQuery(...);
Cache.GetOrAdd(key, _ => EF.CompileQuery((Ctx c, int i) => c.Blogs.First(b => b.Id == i)));
if (_byId is { }) { } else { _byId = EF.CompileQuery((Ctx c, int i) => c.Blogs.First(b => b.Id == i)); }
if (_byId == default) _byId = EF.CompileQuery((Ctx c, int i) => c.Blogs.First(b => b.Id == i));
_cache.GetOrCreate(key, _ => EF.CompileQuery((Ctx c, int i) => c.Blogs.First(b => b.Id == i)));
ImmutableInterlocked.GetOrAdd(ref _queries, id, _ => EF.CompileQuery((Ctx c, int i) => c.Blogs.First(b => b.Id == i)));