The N+1 Query Problem
One list page is firing 1000 database queries
Use Case: The N+1 Query Problem
The everyday problem: You load a list of 100 orders and then, in a loop, access order.Customer.Name or iterate order.Items. The page works fine in development with 10 test orders. In production it crawls. Why? Because behind that innocent property access, EF Core fired one query to load the orders (the "1") and then one additional query per order to lazily load each customer or item collection (the "N"). A list of 100 orders triggers 101 database round-trips. A list of 1,000 triggers 1,001. The page that should take 5 ms takes 5 seconds. This is the single most common performance bug in data-access code, and it hides because it never appears in a single slow query — it is death by a thousand fast ones.
Five ways to detect and eliminate it, from "load the related data up front" to "don't load entities at all".
Way 1: Eager Loading with Include / ThenInclude
Best used for: When you know up front that you need related data for every parent orders with their items, customers with their addresses and you want the related entities fully materialised as navigation properties.
The concept: Include() tells EF Core to fetch the related data in the same query (or a coordinated set of queries) using a JOIN, rather than lazily firing a separate query when you first touch the navigation property. ThenInclude() chains deeper into the graph.
// THE PROBLEM — N+1 via lazy loading or deferred access
var orders = await _db.Orders.ToListAsync(ct); // 1 query: SELECT * FROM Orders
foreach (var order in orders)
{
// Each access fires a SEPARATE query if Customer wasn't loaded:
Console.WriteLine(order.Customer.Name); // N queries: SELECT * FROM Customers WHERE Id = ?
foreach (var item in order.Items) // N more queries for items
Console.WriteLine(item.ProductName);
}
// Total for 100 orders: 1 + 100 (customers) + 100 (items) = 201 round-trips
// THE FIX — eager load everything needed in one coordinated fetch
var orders = await _db.Orders
.AsNoTracking()
.Include(o => o.Customer) // JOIN to Customers
.Include(o => o.Items) // load Items collection
.ThenInclude(i => i.Product) // and each Item's Product
.ToListAsync(ct);
foreach (var order in orders)
{
Console.WriteLine(order.Customer.Name); // already in memory — no query
foreach (var item in order.Items)
Console.WriteLine(item.Product.Name); // already in memory — no query
}
// Total: 1 (or a few) round-trips regardless of order count
Why it works: EF Core fetches the parents and their related data together, so by the time your loop runs, every navigation property is already populated in memory. No query fires inside the loop.
Benchmark note: The N+1 version on 500 orders with customers and items is ~1,001 round-trips; at 2 ms network latency each, that is ~2 seconds of pure latency before any work happens. The Include version is a handful of round-trips and typically completes in 20–50 ms. Watch the cartesian explosion: multiple collection Includes on the same query (e.g. Include(Items) AND Include(Payments)) produce a JOIN that multiplies rows — 10 items × 5 payments = 50 returned rows per order, much of it duplicated parent data. That is the problem split queries (Way 3) solve.
Way 2: Projection with Select (load only what you need)
Best used for: Read-only scenarios — API responses, reports, list views, where you do not need full tracked entities, only specific fields. This is usually the best fix, not just an alternative.
The concept: Instead of loading entire entity graphs, you project directly into a DTO or anonymous type in the query. EF Core generates SQL that selects exactly those columns with the necessary JOINs — no over-fetching, no change tracking, no cartesian duplication of unneeded columns.
// Projection — select exactly the shape the caller needs
var orderSummaries = await _db.Orders
.AsNoTracking()
.Where(o => o.PlacedAt >= since)
.Select(o => new OrderSummaryDto
{
OrderId = o.Id,
CustomerName = o.Customer.Name, // JOIN handled in SQL
ItemCount = o.Items.Count, // aggregated in SQL — COUNT subquery
Total = o.Items.Sum(i => i.Quantity * i.UnitPrice), // SUM in SQL
FirstProduct = o.Items.Select(i => i.Product.Name).FirstOrDefault()
})
.ToListAsync(ct);
// Generated SQL fetches ONLY these columns/aggregates in ONE query.
// No Customer entity loaded, no Items loaded — just the computed shape.
Why it works: You let the database do the joining and aggregating, returning a flat, minimal result. There is nothing to lazily load because you never expose navigation properties, the data is already shaped for the caller.
Benchmark note: Projection is frequently the fastest option and the lowest memory. Loading full Order + Customer + Items entities to compute a count and total wastes bandwidth and RAM on dozens of unused columns. A projected query returning 4 scalar values per order can be 5–10× less data over the wire than the equivalent Include. The aggregates (Count, Sum) execute in the database, not in application memory. Rule of thumb: if the result is read-only and goes out as a DTO, project — don't Include.
Way 3: Split Queries (avoid cartesian explosion)
Best used for: When you genuinely need multiple collection navigations loaded as entities (not projections) and a single JOIN would multiply rows into a cartesian product.
The concept: Instead of one giant JOIN that duplicates parent rows for every combination of children, EF Core issues one query per collection and stitches the results together in memory. You opt in with AsSplitQuery().
// Single query (default) — cartesian explosion with multiple collections
var orders = await _db.Orders
.Include(o => o.Items) // 20 items per order
.Include(o => o.Payments) // 3 payments per order
.ToListAsync(ct);
// One JOIN returns 20 × 3 = 60 rows PER ORDER, with order/customer columns
// duplicated 60 times. 1,000 orders = 60,000 bloated rows over the wire.
// Split query — one query per collection, stitched in memory
var orders = await _db.Orders
.AsNoTracking()
.Include(o => o.Items)
.Include(o => o.Payments)
.AsSplitQuery() // EF issues separate queries, no cartesian blowup
.ToListAsync(ct);
// Query 1: SELECT orders
// Query 2: SELECT items WHERE OrderId IN (...)
// Query 3: SELECT payments WHERE OrderId IN (...)
// 1,000 orders + 20,000 items + 3,000 payments rows — no duplication
Why it works: Each collection is fetched in its own clean query without the multiplicative JOIN. EF Core correlates the children back to their parents in memory. Total rows transferred drops dramatically when you have multiple or large child collections.
Benchmark note: With two collections of 20 and 3 across 1,000 parents, a single query transfers ~60,000 wide rows; split queries transfer ~24,000 narrow rows across 3 round-trips. The break-even depends on collection sizes and row width — single query wins when collections are tiny (1–2 rows) because it avoids extra round-trips; split query wins decisively as collection sizes grow. Caveat: split queries run in separate transactions by default, so data can change between them — wrap in a transaction or a snapshot isolation level if consistency across the collections matters. You can set QuerySplittingBehavior.SplitQuery globally in OnConfiguring if this is your common case.
Way 4: Explicit / Batched Loading
Best used for: When you have a parent (or small set of parents) already in memory and conditionally need related data only sometimes, or want to filter the related data as you load it.
The concept: Rather than eager-loading everything up front (wasteful if you often don't need the children) or lazy-loading per access (N+1), you explicitly load the related data in one batched query at the point you decide you need it, using Load() with a filter, or load children for many parents at once with a single WHERE ... IN query.
// Explicit load — fetch related data on demand, filtered, in ONE query
var order = await _db.Orders.FirstAsync(o => o.Id == id, ct);
// Load only the non-cancelled items, in a single query, when needed:
await _db.Entry(order)
.Collection(o => o.Items)
.Query()
.Where(i => i.Status != "Cancelled") // filter applied in SQL
.LoadAsync(ct);
// Manual batch loading — kill N+1 when you already have a list of parents
var orders = await _db.Orders.AsNoTracking().Where(o => o.PlacedAt >= since).ToListAsync(ct);
var orderIds = orders.Select(o => o.Id).ToList();
// ONE query loads all items for all orders, then group in memory
var itemsByOrder = (await _db.OrderItems
.AsNoTracking()
.Where(i => orderIds.Contains(i.OrderId)) // single WHERE IN query
.ToListAsync(ct))
.ToLookup(i => i.OrderId);
foreach (var order in orders)
{
var items = itemsByOrder[order.Id]; // in-memory lookup — no query
// process items...
}
// Total: 2 round-trips (orders + all items) instead of 1 + N
Why it works: The batched WHERE ... IN query collapses what would have been N separate child queries into one. The in-memory ToLookup gives O(1) access to each parent's children without further database calls.
Benchmark note: This pattern is what DataLoader/GraphQL batching libraries automate under the hood. For 1,000 parents it turns 1,001 round-trips into 2. The WHERE ... IN clause has practical limits — SQL Server historically struggles with parameter lists beyond a few thousand values (each becomes a parameter), so for very large parent sets, chunk the IDs into groups of ~1,000 or join against a temp table / TVP instead. For PostgreSQL, WHERE id = ANY(@ids) with an array parameter avoids the parameter-count limit entirely.
Way 5: Disable Lazy Loading + Detect at Dev Time
Best used for: Preventing N+1 from ever shipping — a configuration and tooling strategy rather than a per-query fix.
The concept: Lazy loading is the silent enabler of N+1 — it makes a database round-trip look like a harmless property access. If you do not enable lazy-loading proxies, accessing an unloaded navigation returns null/empty instead of secretly querying, forcing developers to load related data explicitly and consciously. Combine this with query-count logging in development to catch N+1 before production.
// 1. Do NOT enable lazy loading proxies (they are opt-in — just don't add them):
// builder.Services.AddDbContext<AppDb>(o => o.UseLazyLoadingProxies()); // AVOID
// 2. Log every SQL command in development so N+1 shows up as a wall of queries
builder.Services.AddDbContext<AppDb>((sp, options) =>
{
options.UseSqlServer(connStr);
if (builder.Environment.IsDevelopment())
{
options.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging() // show parameter values (DEV ONLY)
.EnableDetailedErrors();
}
});
// 3. Catch N+1 in tests by asserting query count
public class QueryCountInterceptor : DbCommandInterceptor
{
public int Count { get; private set; }
public override ValueTask<DbDataReader> ReaderExecutingAsync(
DbCommand command, CommandEventData eventData,
InterceptionResult<DbDataReader> result, CancellationToken ct = default)
{
Count++;
return base.ReaderExecutingAsync(command, eventData, result, ct);
}
}
// In a test:
// var interceptor = new QueryCountInterceptor();
// ... run the endpoint ...
// Assert.True(interceptor.Count <= 3, $"Possible N+1: {interceptor.Count} queries fired");
Why it works: Without lazy-loading proxies, an N+1 cannot happen by accident — you must consciously load related data, which naturally pushes you toward Include or projection. The query-count assertion turns "N+1 regression" into a failing test rather than a production incident.
Benchmark note: This is prevention, not a runtime optimisation — its value is catching the problem at the cost of zero production overhead. Many teams add a middleware that logs a warning when a single HTTP request fires more than a threshold of queries (say 10), surfacing N+1 in real traffic. The interceptor approach above adds negligible overhead and is worth wiring into integration tests for any data-heavy endpoint.
Choosing the right approach
- Read-only data going out as a DTO → Projection with Select (Way 2) — usually the best
- Need full entities, single small collection → Include / ThenInclude (Way 1)
- Need full entities, multiple/large collections → Include + AsSplitQuery (Way 3)
- Conditional or filtered related data → Explicit / batched loading (Way 4)
- Prevent N+1 across the whole codebase → No lazy proxies + query-count tests (Way 5)
The mental model: N+1 is always "one query per item in a loop". The fix is always "load the related data in a bounded number of queries before the loop". Whether you do that with a JOIN (Include), a shaped query (Select), separate queries (AsSplitQuery), or a batched WHERE IN (explicit) depends on whether you need entities or DTOs and how large the child collections are. The first thing to do when a list endpoint is slow is turn on SQL logging and count the queries — N+1 announces itself instantly.
Note: this is an informational engineering overview. Benchmark figures are illustrative order-of-magnitude estimates that depend on schema, indexes, latency, row width, and EF Core version. Always measure against your own workload.