NP0002: A lazy-loaded navigation is read once per element
Default severity: Warning · Code fix: adds the Include
What it detects
With lazy loading on, reading a navigation that the query did not load runs one query per element. The analyzer
reports a navigation read on an element of an iteration (a foreach variable, the element parameter of a LINQ
lambda, a list or array read at a for loop's counter (orders[i]), a part of a tuple or key/value pair built
from the element (foreach (var (id, o) in orders.Select(o => (o.Id, o))), pair.Value over
orders.ToDictionary(o => o.Id), orders.Index()), a navigation of one, a local copied from one) when that element comes from an EF query that did not
Include the navigation. It follows the elements through repositories (await repository.GetAllAsync()), nested
loops (c.Orders → o.Lines → l.Product) and mappers (orders.Select(Mapper.ToDto), where ToDto reads
order.Customer).
ABP's repositories too: await books.GetListAsync() loads no details, so book.Author per element is reported;
WithDetails(b => b.Author) includes Author; includeDetails: true and WithDetails() load the details the
application configures (DefaultWithDetailsFunc), which the analyzer cannot read, so it stays quiet on them. In an ABP
application layer, which references no EF Core, say that lazy loading is on with nplusone.lazy_loading = enabled.
A navigation read after loading it explicitly (Entry(o).Reference(x => x.Customer).Load(), ABP's
EnsurePropertyLoadedAsync) loads nothing; the explicit load is the round trip (NP0001).
var orders = await db.Orders.ToListAsync();
foreach (var o in orders)
{
Console.WriteLine(o.Customer.Name); // NP0002: 'Customer' is lazy-loaded once per element
}
It also follows navigations read inside the entity's own members and inside helpers:
-
a computed property or method of the entity (
public decimal Total => Lines.Sum(l => l.Amount);) read or called on each element:invoices.Sum(i => i.Total)lazy-loadsLinesonce per invoice; -
a sequence of entities passed to a method that walks it and reads their navigations (
PrintCustomers(orders), wherePrintCustomersloops over its parameter and readso.Customer), also when the method hands each element to a mapper. A method that reads one element (Name(list.First())) is one lazy load, not N, and is not reported. -
an object mapper call on a result (or on each element in a loop) whose configuration is in the project: AutoMapper's
Mapwith aCreateMap<TSource, TDestination>()(itsForMember(..., o => o.MapFrom(...))/Ignore()overrides, also after other chained calls, up toReverseMap()), or Mapster'sAdapt/IMapper.Mapwith aTypeAdapterConfig<TSource, TDestination>.NewConfig()(itsMap/Ignoreoverrides). The conventions (a member named like a navigation, flattened names such asCustomerName) decide the other members. Without a visible configuration the same reads are reported as the suggestion NP0007. AutoMapper's other ways of filling a member count too: a value resolver (MapFrom<BuyerResolver>(), whoseResolvereadssource.Customer; a member value resolver reads on the member it is given),ForPath(d => d.Buyer.Name, o => o.MapFrom(s => s.Customer.Name))(aForPathtakes its root member away from the conventions, also when it ignores the path),IncludeMembers(s => s.Customer)(members the source cannot fill come from the included member), andConvertUsingwith a type converter or a lambda, which replaces the whole map.Mappassed as a method group (orders.Select(mapper.Map<OrderView>)) maps each element like a call. -
a source-generated mapper (Mapperly's
[Mapper] partial class): its generated method bodies are read like hand-written ones, somapper.ToView(order)per element reads what the generated mapping reads. -
members that run without being named: an entity's
ToStringoverride (or a record's generated printer, which reads every navigation) where the entity is formatted ($"{desk}",string.Join(", ", desks),Console.WriteLine(desk),TextWriter.Write,StringBuilder.Append), itsEquals/GetHashCodeoverrides where entities are hashed (Distinct,ToHashSet,Union,Intersect,Except,new HashSet<T>(...); a record's generated equality compares fields and loads nothing), a user-defined conversion operator (DeskLabel label = desk;), and property patterns (d is { Floor.Name: "x" }, nested patterns,switch); -
a loop that walks a lazy chain (
while (floor.Parent != null) floor = floor.Parent;): one row per step. -
a method of the project called in a query's final projection (
db.Orders.Select(o => Describe(o)), whereDescribereadso.Customer): EF Core materializes each row and calls the method on the client.
Repeated reads of the same navigation on the same element are reported once.
A navigation counts as loaded, and its reads are not reported, when the query includes it, when the model
auto-includes it (builder.Navigation(x => x.Customer).AutoInclude() in OnModelCreating or an
IEntityTypeConfiguration) and the query does not call IgnoreAutoIncludes(), or when nothing can lazy-load
it: the method sets ChangeTracker.LazyLoadingEnabled = false before the read, or the query is AsNoTracking() /
AsNoTrackingWithIdentityResolution() and either the project uses EF Core 7 or older (which never lazy-loads
untracked entities) or the navigation is a reference whose foreign key is a shadow property (EF Core 8 and later
throw for it instead of loading). From EF Core 8 on, the other navigations of untracked entities do load lazily,
one query per read.
When lazy loading counts as on
nplusone.lazy_loading decides: enabled, disabled, or auto (the default), which is on when the project
references Microsoft.EntityFrameworkCore.Proxies (for virtual navigations) or an entity takes an ILazyLoader
or the lazy-loader delegate in its constructor. On such an entity only the navigations whose getter asks the
loader (get => LazyLoader.Load(this, ref _posts)) load lazily; a plain auto-property navigation stays null
until included and is not reported. In a project that uses entities but does not reference the proxies
package itself (a Clean Architecture application layer), set nplusone.lazy_loading = enabled.
How to fix it
Include the navigation in the query that produced the elements; the code fix does it, in that query's file:
var orders = await db.Orders.Include(x => x.Customer).ToListAsync();
Or project only what you need, which also avoids loading whole entities:
var rows = await db.Orders.Select(o => new { o.Id, CustomerName = o.Customer.Name }).ToListAsync();
What it cannot see
Elements whose query is unknown (a list passed in as a parameter, a field), navigations read through reflection or
a serializer (see NP0005 for endpoints), and owned types configured without a key convention. Parts of
elements are followed only where the code shows how each element was built (a tuple, a key/value pair or an object in
Select, ToDictionary, Index, Zip), for a foreach variable or a lambda's element; a part that is the same
entity in every element ((o, customer)) is one lazy load and is not reported. An index is followed when the for
loop steps it, or a while loop's body steps a local declared before it.