Skip to main content

N+1 next to other .NET tools

Measured 2026-10-13. What other tools catch N+1 work in EF Core code, how they do it, and how they do on this repository's corpus.

The landscape​

ToolKindHow it finds N+1Lazy loadsSerialized entitiesWritesLicense
N+1 (this repository): NPlusOne.AnalyzersRoslyn analyzerRound trips (queries, lazy loads, saves) reached once per element, through calls, interfaces, referenced projects, mediators, mappers, getters, delegatesyes (NP0002)yes (NP0005)yes (NP0003)MIT
N+1: NPlusOne.EntityFrameworkCore / .AspNetCoreRuntime guardSame SQL fingerprint or lazy-loaded navigation from the same call site above a threshold, per scope (test, request)yesyes (at run time)opt-inMIT
LinqContrabandRoslyn analyzer, 63 EF Core rulesLC007 database execution in a loop, LC010 SaveChanges in a loop, LC039 repeated SaveChanges, LC045 navigation read without IncludeLC045 (any read without Include, not only per element)noLC010, LC039MIT
Microsoft.EntityFrameworkCore.AnalyzersRoslyn analyzer, ships with EF Corenone (internal API use, interpolated raw SQL)nononoMIT
EFCore.QueryGuardRuntime interceptorSame SQL text above a threshold (default 3) per scope; slow and too many queriesas repeated SQLat run timestatements counted one by oneMIT
DbEyeRuntime middlewareIdentical SQL text in one request (default 2+), per endpoint thresholds; development onlyas repeated SQLat run timeonly statements that return rowsMIT
KiwiQuery.EFCoreTest helperCounts queries in integration tests (AssertCount); no N+1 rule of its ownas countsas countsas countsMIT
EfCore.QueryExplainRuntime interceptorSame query shape with different parameters per context; marked an early scaffoldnot statedat run timenot statedMIT
MiniProfiler, Entity Framework Profiler, JetBrains Dynamic Program AnalysisProfilersDuplicate or repeated queries shown while the application runsas queriesat run timeas queriesMIT / commercial / commercial

The runtime tools all see the same thing at heart: SQL that repeats. N+1's runtime guard adds the call site and the lazy-loaded navigation to each group, counts one SaveChanges once however many statements it sends, sees transactions started per element, and is checked against every corpus case (RuntimeGuardTests).

Among the static tools only LinqContraband targets N+1 directly. It is broader than this analyzer elsewhere (cartesian explosions, client evaluation, sync over async, raw SQL injection and more), and narrower on N+1.

LinqContraband on the corpus​

ComparisonTests (NPLUSONE_COMPARE=1) runs LinqContraband 5.17.0 over every corpus folder and scores it method by method against the ground truth: a method whose markers SQLite proves (more SQL for more rows) is an N+1 to find, a method without markers one that SQLite proves constant. Only LinqContraband's N+1 rules count (LC007, LC010, LC039, LC045).

Proven N+1 methods foundProven-constant methods flagged
N+1 (NPlusOne.Analyzers)260 of 2600 of 472
LinqContraband 5.17.055 of 260 (21%)17 of 472

By kind of N+1 (this analyzer's rule ids):

N+1LinqContraband
Query per element (NP0001)110 of 11034 of 110
Lazy load per element (NP0002)85 of 8518 of 85
Save per element (NP0003)11 of 113 of 11
Serialized lazy entities (NP0005)54 of 540 of 54
Convention mappers (NP0007)2 of 20 of 2

Read these numbers with their bias in mind: the corpus was written case by case for this analyzer, so its own score is 100% by construction; the comparison shows which of these proven cases another tool sees, not how either tool does on code neither has seen. LinqContraband's 17 flags on constant methods are 4 LC007 and 2 LC010 warnings on loops that page, poll, retry, drain or batch, and 11 LC045 warnings, a rule that is wider than N+1 by design (a navigation read without Include may also be null when nothing loads it): some of those are reads of one entity, or of an entity shared by every element, and one is in the folder with lazy loading off, where LC045's warning about a null navigation is right.

The runtime tools on the corpus​

ComparisonTests also runs every corpus method on SQLite (6 seeded rows per entity) under each runtime tool with its default settings, attached the way its documentation says, and scores it the same way: a method the ground truth proves grows should be reported, one it proves constant not. EFCore.QueryGuard 1.0.1 runs its interceptor in one of its scopes per method; DbEye 1.3.0 collects through its interceptor and decides with its own middleware, run at the end as at the end of a request; MiniProfiler 4.5.4 profiles each method with its EF Core listener, and a SQL command whose text repeats in one profile counts as found (what its UI marks as a duplicate). 36 methods cannot run in the harness and count for none.

Proven N+1 methods foundProven-constant methods flagged
N+1 runtime guard (NPlusOne.EntityFrameworkCore, writes included)265 of 2650 of 295
EFCore.QueryGuard 1.0.1263 of 2656 of 295
DbEye 1.3.0262 of 2658 of 295
MiniProfiler 4.5.4 (duplicates)18 of 2653 of 295

The tools that group repeated SQL see nearly every case, lazy loads and serialized entities included, since those are repeated SQL too. Where they differ:

  • One SaveChanges with many rows. QueryGuard and DbEye count the statements of one SaveChanges (which EF Core batches into one round trip, committed together) as a repeated query, so a save after the loop is reported, and so is an ABP unit of work that saves what the loop changed at its end (6 of their false alarms). The guard here had the same error until the corpus run found it.
  • SQL text that differs per element. A query built at run time with the element's value as a constant (Expression.Constant(id)) sends different SQL text each time: QueryGuard and DbEye miss it; the guard here normalizes literals and parameters before grouping.
  • Transactions per element are seen by none of the others; ExecuteUpdate/ExecuteDelete per element are missed by DbEye, which records only commands that return rows.
  • Thresholds. DbEye reports a query run twice (its default NPlus1Threshold is 1), so a keyset paging loop of two pages, or a timer poll of two ticks, is flagged; the guard here and QueryGuard report the third run.
  • MiniProfiler is a profiler, not an N+1 detector: its duplicate mark needs the same command text with the same parameter values, which an N+1 rarely sends.

The guard's 265 of 265 is again by construction (it is tested against this corpus); the useful part is where the other tools fall short and why.

Real projects​

tools/scan.sh on open-source EF Core applications (2026-10-13, built with the 10.0.105 SDK, see docs/backlog.md):

ProjectFindingsTriage
dotnet/eShop (dc7ea49): Catalog.API, Ordering.API, Webhooks.API, Identity.API, OrderProcessor8all real: CatalogItems.Find per ordered item (2); per pending outbox event a Single lookup and a SaveChanges (6; the saves are the outbox's own per-event bookkeeping, the lookups avoidable)
ardalis/CleanArchitecture (e7c79be): the template, the sample, MinimalClean1real, in seed data: an INSERT per contributor in a loop
jasontaylordev/CleanArchitecture (bf7665f): Web0
abpframework/abp-samples: the 82 of 100 EF Core solutions that build1real, in StudioAiGeneratedProjects/MovieCollectionApp: GetListAsync maps each movie with a helper that runs _genreRepository.FindAsync(movie.GenreId). Before ABP support (item 8) the scan found nothing: ABP application layers were not analysed at all
nopSolutions/nopCommercenot scanneduses linq2db, not EF Core