The N+1 Query Problem
By the end of this lesson
Recognise, reproduce and fix the most common EF Core performance bug.
This is the bug the rest of this module exists to prevent. It is the most common EF Core performance problem by a wide margin, and the reason is not that it is subtle. It is that in C# it looks like nothing at all.
The name comes from the query count. One query to fetch a list of N things, then one more query per thing. Two hundred orders means two hundred and one queries, and each one is a separate round trip across a connection.
A round trip costs a millisecond or two on a good day, more across a network, more again if the database is in another region. Multiply by a few hundred and a page that should take 50ms takes several seconds — and every individual query in the log looks fast.
// Lazy loading is enabled, so Customer and Items are virtual navigations.
List<Order> orders = await context.Orders
.Where(o => o.OrderDate >= from)
.ToListAsync(); // 200 orders, 200 different customers
decimal grandTotal = 0m;
foreach (Order order in orders)
{
// Ordinary property access. Also a database round trip, every time.
Console.WriteLine($"{order.Id}: {order.Customer.Name}");
// And another one, for the collection.
grandTotal += order.Items.Sum(i => i.Quantity * i.UnitPrice);
}- order.Customer loads one customer. order.Items loads a collection. Both happen on first access, which is inside the loop.
- The count is 1 + 200 + 200 = 401 queries to render one screen.
- In development, with ten orders and a database on the same machine, this takes about 40 milliseconds and nobody notices anything.
- Read the loop again. By the standards of ordinary C#, nothing here is written badly. That is the entire problem.
-- 1. Once
SELECT [o].[Id], [o].[CustomerId], [o].[OrderDate], [o].[Status]
FROM [Orders] AS [o]
WHERE [o].[OrderDate] >= @__from_0;
-- 2. Once per order. 200 times, with a different parameter each time.
SELECT [c].[Id], [c].[Name], [c].[Email]
FROM [Customers] AS [c]
WHERE [c].[Id] = @__p_0;
-- 3. Once per order. 200 more times.
SELECT [i].[Id], [i].[OrderId], [i].[ProductId], [i].[Quantity], [i].[UnitPrice]
FROM [OrderItems] AS [i]
WHERE [i].[OrderId] = @__p_0;- Every one of those statements is fast. Look at any single line of the log and there is nothing to fix.
- The cost is the count, and the count is invisible unless somebody is counting. This is why query logging in development is not an advanced technique — it is the only way to see this class of bug.
- Each round trip pays network latency and connection overhead regardless of how little data it moves. Four hundred of them against a database in another data centre is seconds, not milliseconds.
- The signature to learn: the same statement repeating in the log with only the parameter changing. Once you know that shape you will spot it in ten seconds.
// Fix 1 — keep the entities, fetch the related data with them.
List<Order> orders = await context.Orders
.Where(o => o.OrderDate >= from)
.Include(o => o.Customer)
.Include(o => o.Items)
.ToListAsync();
// Fix 2 — ask for the values the screen uses, and nothing else.
public record OrderRow(int Id, string CustomerName, decimal Total);
List<OrderRow> rows = await context.Orders
.Where(o => o.OrderDate >= from)
.Select(o => new OrderRow(
o.Id,
o.Customer.Name,
o.Items.Sum(i => i.Quantity * i.UnitPrice)))
.ToListAsync();- Fix 1 sends one query with joins. The loop that follows touches the database zero times, and the entities are still tracked, so code that goes on to change and save an order keeps working.
- Fix 2 sends one query for three values per order. No entities, no tracking, and the total is summed by the database instead of by fetching every order item.
- Both take the count from 401 to 1. Which you choose depends on whether anything downstream needs the entity, not on which is faster in the abstract.
- Neither fix requires turning lazy loading off, but turning it off is worth considering: it converts this bug from a silent slowdown into a null reference you find immediately.
SELECT [o].[Id], [c].[Name],
COALESCE((
SELECT SUM([i].[Quantity] * [i].[UnitPrice])
FROM [OrderItems] AS [i]
WHERE [o].[Id] = [i].[OrderId]), 0.0) AS [Total]
FROM [Orders] AS [o]
INNER JOIN [Customers] AS [c] ON [o].[CustomerId] = [c].[Id]
WHERE [o].[OrderDate] >= @__from_0;- One statement, 200 rows, three columns each. The aggregation happens where the data already is.
- Compare that with the 401 statements above. The work did not disappear — it moved to the component that is built for it, and the round trips went with it.
- Log your own version rather than assuming it matches this one. The shape depends on your model, your provider and your EF Core version.
The two fixes are not interchangeable, and the difference is worth being deliberate about:
| Fix with Include | Fix with projection | |
|---|---|---|
| Queries sent | One | One |
| Data returned | Every column of Order, Customer and OrderItem | Only the values named in the Select |
| Row count | One row per order item, with parent columns repeated | One row per order |
| Change tracking | Entities tracked and ready to save | Nothing tracked |
| Aggregates | Computed in memory after fetching the children | Computed by the database |
| Choose when | The code will modify and save these entities | The data is going to a screen, a report or an API response |
Summary
- N+1 means one query for a list, then one more per item, usually from a navigation property inside a loop
- Every individual query looks fine; the problem is the count, and the count is invisible without logging
- In C# it reads as ordinary property access, which is why it survives review until a list grows
- Fix it with Include when the entities are needed, or with a projection when the data is read-only
- Learn the log signature: the same statement repeating with only the parameter changing
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Reproduce it and count
Turn on SQL logging. Write a loop over twenty orders that reads order.Customer.Name, and count the statements in the log.
Apply each fix in turn and count again after each one.
Then make the loop read order.Items as well and count once more before fixing it.
Show solution
Twenty-one statements for the first version, then one for each fix. Adding the collection access takes it to forty-one before the fix and leaves it at one afterwards.
Counting is the skill being practised here, not fixing. The fix is two lines and takes a minute; noticing that a page sends forty-one queries is the part that needs a habit.
Keep the count in mind as a number you can estimate before running anything. If a page shows a list of N items and touches a navigation property per item, the query count is about N plus one per navigation. That arithmetic is usually enough to find the problem without a profiler.
Challenge
Fix it when you cannot change the query
A data-access method you are not allowed to change returns List<Order> with no Includes. The view it feeds reads order.Customer.Name for every row, and the page is slow.
Name three ways to remove the N+1 and say what each one costs.
Show solution
First: change the view to consume a projection instead. This is usually the right answer and it costs a new read model plus a change to the contract between the two layers.
Second: add the Include inside the data-access method. One line, and now every caller of that method fetches customers whether they need them or not. Acceptable if they all do; a hidden cost if they do not.
Third: load the related data in one extra query before the view runs. Collect the customer ids, fetch those customers in a single tracked query, and EF Core's fixup wires them onto the orders because they are in the same context. Two queries instead of two hundred and one, without touching either layer's signature.
The third option is the interesting one, because it relies on the identity map from the change tracking lesson. It is also the least obvious to a reader, so it deserves a comment explaining why a query whose result is never used is there on purpose.
List<Order> orders = await legacyRepository.GetRecentOrdersAsync();
// One query for every customer those orders need. The results are not used
// directly: loading them tracked is enough for EF Core to fill in
// order.Customer on each order.
int[] customerIds = orders.Select(o => o.CustomerId).Distinct().ToArray();
await context.Customers
.Where(c => customerIds.Contains(c.Id))
.LoadAsync();Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.