Analyzer rule

LC023: Use Find/FindAsync for Primary Key Lookups

LC023 suggests Find or FindAsync when FirstOrDefault or SingleOrDefault looks up an EF Core entity by primary key, so tracked entities skip the database.

Default severity
Info
Code fix
Available
Category
Performance

Change the severity in .editorconfig:

[*.cs]
dotnet_diagnostic.LC023.severity = error

Sample code · Analyzer source

LC023: Use Find/FindAsync for Primary Key Lookups

In Plain Terms

Imagine you want to know if you have a blue shirt. FirstOrDefault is like driving to the store to buy a new one just to check. Find is like looking in your closet first. If it’s in the closet, you save a trip!

Goal

Suggest Find(...) or FindAsync(...) when a query directly looks up a DbSet entity by its primary key with FirstOrDefault, SingleOrDefault, FirstOrDefaultAsync, or SingleOrDefaultAsync.

Why It Matters

FirstOrDefault and SingleOrDefault always execute a query. Find first checks the EF Core change tracker and can return an already-loaded entity without another database round trip.

Violation

var user = db.Users.FirstOrDefault(user => user.Id == userId);

Preferred

var user = db.Users.Find(userId);

For awaited async lookups, the fixer preserves explicit cancellation tokens by using EF Core’s token-aware overload:

var user = await db.Users.FindAsync(new object[] { userId }, cancellationToken);

Analyzer Behavior

LC023 reports only when all of these are true:

  • The method is FirstOrDefault, SingleOrDefault, FirstOrDefaultAsync, or SingleOrDefaultAsync.
  • The receiver is directly a DbSet<TEntity>.
  • The predicate is a simple equality check against the proven entity primary key.

The analyzer intentionally stays silent for chained queries such as AsNoTracking(), Include(...), Where(...), and other shapes where replacing the materializer with Find would change tracking, includes, filters, or return semantics.

When visible EF Fluent API configuration declares EntityTypeBuilder<TEntity>.HasKey(...), that configuration takes precedence over convention-based Id/{EntityName}Id inference. LC023 reports for a single configured key property, stays silent when the predicate targets a different convention property, and ignores unrelated generic helper methods named HasKey so non-EF metadata cannot override the entity key. Attribute-based fallback requires the real System.ComponentModel.DataAnnotations.KeyAttribute; project-local same-name attributes are ignored. The analyzer also stays silent for composite or otherwise unsupported key expressions because Find(...) would require the complete key value set in the configured order.

EF Core’s class-level key attributes are honored too: [Keyless] and HasNoKey() entities stay silent (Find throws on a keyless entity type), [PrimaryKey(nameof(ExternalId))] makes ExternalId the key instead of Id, and a composite [PrimaryKey(a, b)] stays silent. Fluent configuration wins over the attributes, which win over [Key] and the Id/{EntityName}Id convention, matching EF’s own precedence.

Configuration is read from the whole compilation

HasKey, HasNoKey, HasQueryFilter, SetQueryFilter and Finbuckle IsMultiTenant calls are found wherever they live in the project (the DbContext, IEntityTypeConfiguration<T> classes, or any other file), however many files the project has. The scan runs once per compilation and only binds files whose text mentions one of those names, so its cost follows the size of the model configuration rather than the size of the project. Up to 5.10.0 the analyzer skipped this scan and the convention-key fallback in projects with more than 64 syntax trees, which silenced it on x.Id == id in most real applications.

Global query filters gate the rule

Find checks the change tracker before querying, and a tracker hit bypasses global query filters — only the database fallback applies them (EntityFinder falls through to queryRoot.FirstOrDefault(...)). On an entity configured with HasQueryFilter(...) (soft delete, multi-tenancy), rewriting FirstOrDefault(x => x.Id == id) to Find(id) can therefore return an already-tracked row the filtered query would have excluded — a silent correctness change, not a perf win. LC023 stays completely silent for entities with a visible HasQueryFilter(...) — in OnModelCreating, an EntityTypeBuilder<TEntity> configuration class, or the non-generic modelBuilder.Entity(typeof(X)).HasQueryFilter(...) form. The check uses the DbSet’s entity type (a key inherited from a base class doesn’t dodge the gate) and walks base types, since EF declares filters on the hierarchy root and propagates them to derived entities. A lookalike HasQueryFilter on a non-EF builder type does not suppress.

Because a missed filter turns the advice into a correctness bug, LC023 also stays silent whenever an entity may be filtered by configuration it cannot read:

  • Model configuration in another assembly. When the lookup goes through a context (db.Users, db.Set<User>()) and that context, or one of its base types, overrides OnModelCreating in a referenced assembly, every entity of that context may be filtered. This covers shared base contexts such as fullstackhero’s BaseDbContext, whose OnModelCreating loops over entity types and applies AppendGlobalQueryFilter<ISoftDeletable>. EF Core’s own DbContext and Microsoft bases such as IdentityDbContext add no query filters, so their OnModelCreating does not count; a referenced base context without an override does not count either.
  • Finbuckle multi-tenancy. A context deriving from a Finbuckle base (MultiTenantDbContext, MultiTenantIdentityDbContext), an entity marked [MultiTenant], and an entity configured with IsMultiTenant() are all treated as filtered.
  • Filters whose entity cannot be named. modelBuilder.Entity(type).HasQueryFilter(...) with a runtime Type (a loop over modelBuilder.Model.GetEntityTypes()) and IMutableEntityType.SetQueryFilter(...) silence the rule for every entity in the project.
  • Generic helpers. modelBuilder.Entity<T>().HasQueryFilter(...) (or an EntityTypeBuilder<T> configuration base class) inside a generic method or type treats every entity that satisfies T’s constraints as filtered: where T : ISoftDeletable covers the soft-deletable entities only, and an unconstrained T covers all of them.
  • Unknown context. When the receiver is a bare DbSet<T> parameter or field, LC023 stays silent if any DbContext declared in the project has model configuration in another assembly.

A filter configured in another assembly for a context that is never visible from the project (for example, a DbSet<T> passed in from a library whose context the project does not declare or reference by type) is still invisible; treat Find on filtered entities as a manual review point.

Fresh contexts from IDbContextFactory

LC023 stays silent when the DbSet belongs to a local context created by IDbContextFactory<TContext>.CreateDbContext() or CreateDbContextAsync() (including using var/await using var declarations). A fresh context has an empty change tracker, so Find would run the same query and the advice has no benefit. A context injected through the constructor, reached through a field, or held in a factory local that is reassigned before the lookup still reports.

Fixer Behavior

The code fix rewrites simple synchronous lookups to Find(key) and awaited async lookups to FindAsync(key).

When the awaited async lookup passes an explicit cancellation token, the fixer rewrites to FindAsync(new object[] { key }, cancellationToken) so the token is preserved. The fixer does not rewrite non-awaited async calls because EF Core FindAsync returns ValueTask<TEntity?>, which can be incompatible with a call site expecting Task<TEntity?>.

Find compares the key value’s type with the key property’s type and throws ArgumentException when they differ, even where the query compiled through an implicit conversion. So when an int value is compared with a long key, the fix casts it: tags.Find((long)id). It offers no fix for a nullable value compared with a non-nullable key (t.Id == id with an int? id is simply false for null) or for a value that reaches the key type through a user-defined conversion, such as a strongly typed ID.

The fixer also requires the key value to be independent of the predicate parameter. LC023 may still report a column-to-column predicate such as users.FirstOrDefault(x => x.Id == x.OtherId), but it stays manual because rewriting to users.Find(x.OtherId) would reference x outside the lambda and break the build.

Non-Goals

LC023 does not rewrite partial composite-key lookups, non-lambda Fluent API key expressions, column-to-column key predicates, fake key attributes, or provider-specific behavior beyond visible HasKey(...)/HasNoKey(), EF Core’s [Keyless]/[PrimaryKey], real [Key], and convention metadata. Key configuration in another assembly, or built through a helper that calls HasKey indirectly, is not visible. A factory-created context that has already loaded entities earlier in the same method is treated as fresh, so a possible change-tracker hit there is not reported. Those shapes should be handled manually unless future analyzer metadata can prove the rewrite is safe.