Skip to main content

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-loads Lines once per invoice;

  • a sequence of entities passed to a method that walks it and reads their navigations (PrintCustomers(orders), where PrintCustomers loops over its parameter and reads o.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 Map with a CreateMap<TSource, TDestination>() (its ForMember(..., o => o.MapFrom(...)) / Ignore() overrides, also after other chained calls, up to ReverseMap()), or Mapster's Adapt / IMapper.Map with a TypeAdapterConfig<TSource, TDestination>.NewConfig() (its Map / Ignore overrides). The conventions (a member named like a navigation, flattened names such as CustomerName) 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>(), whose Resolve reads source.Customer; a member value resolver reads on the member it is given), ForPath(d => d.Buyer.Name, o => o.MapFrom(s => s.Customer.Name)) (a ForPath takes 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), and ConvertUsing with a type converter or a lambda, which replaces the whole map. Map passed 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, so mapper.ToView(order) per element reads what the generated mapping reads.

  • members that run without being named: an entity's ToString override (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), its Equals/GetHashCode overrides 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)), where Describe reads o.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.