← All posts

The EF Core N+1 problem: 51 queries from one loop

A report loop that looks harmless sends a separate SELECT per customer through lazy loading. Real SQL logs, timings at three data sizes, and two fixes.

Artur Kot 8 min read

This report prints every customer in a shop, how many orders they placed and what those orders add up to:

foreach (var customer in db.Customers.ToList())
{
    var total = customer.Orders.Sum(o => o.Total);
    lines.Add($"{customer.Name}: {customer.Orders.Count} zamówienia, razem {total} zł");
}

The database holds 50 customers with three orders each. It compiles, it doesn’t throw, and the first line of output is exactly right:

Klient 1: 3 zamówienia, razem 36.0 zł

(The code and data come from a Polish demo shop: “Klient” is a customer, “zamówienia” are orders, “razem” is the total.) A test comparing these lines to the expected ones passes too. The output isn’t the problem.

The number of times the program asked the database for it is. That number is 51. This is the EF Core N+1 problem, and what makes it nasty is that no line of the report looks like it sends a query at all.

What EF Core actually sent

The model is ordinary. Customer has an Orders collection, and the context turns on lazy loading through Microsoft.EntityFrameworkCore.Proxies:

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public virtual List<Order> Orders { get; set; } = new();
}

protected override void OnConfiguring(DbContextOptionsBuilder options)
{
    options.UseSqlite("Data Source=shop.db").UseLazyLoadingProxies();
}

virtual on Orders plus UseLazyLoadingProxies() makes EF Core generate a subclass of Customer at runtime. Its Orders property queries the database the first time anything reads it.

To see what reached SQLite, I added a DbCommandInterceptor that counts executed commands and turned on LogTo for RelationalEventId.CommandExecuted. The first three commands the report ran:

-- query 1
SELECT "c"."Id", "c"."Name"
FROM "Customers" AS "c"

-- query 2
SELECT "o"."Id", "o"."CustomerId", "o"."Total"
FROM "Orders" AS "o"
WHERE "o"."CustomerId" = @p

-- query 3
SELECT "o"."Id", "o"."CustomerId", "o"."Total"
FROM "Orders" AS "o"
WHERE "o"."CustomerId" = @p

When the report finished, the counter read 51.

One query fetches the customers. That’s the “1”. Then the loop reaches customer.Orders, and instead of handing back an empty list the proxy sends a separate SELECT for that one customer’s orders. Fifty times, because there are fifty customers. That’s the “N”.

Nothing in the report reads like a query. customer.Orders.Sum(...) looks like an in-memory list operation, and once the proxy has loaded the collection, it is one. The query hides inside a property getter.

Here’s why the EF Core N+1 problem survives development. The query count grows with the data, and a dev database rarely has much. I ran the same report against three database sizes, one warm-up pass and twenty measured runs each, and took the median:

CustomersQueriesWithout the fixWith Include
562.0 ms0.9 ms
505116–19 msabout 8 ms
500501170–200 ms20–32 ms

Setup: SQLite in a file on the same machine, EF Core 10, .NET SDK 10.0.401. A range is the median across several series, which disagreed with each other more than I’d like; the five-customer row is a single series. The spread doesn’t change the picture, because the gap between columns is many times wider than the gap between runs.

At five customers the difference is a fraction of a millisecond and nobody clicking around the app will notice. At five hundred, the fixed report is six to eight times faster. The code didn’t change. Only the row count in Customers did, which is why this tends to show up after the deploy, when the first large customer imports their data.

And this is SQLite, running inside the app’s own process, so every query skips the network. Against a database server each extra query is a network round trip, which makes the table above the gentlest case there is. I didn’t measure a remote PostgreSQL or SQL Server, so I won’t quote numbers for one, but the direction isn’t in doubt.

Include: one query instead of 51

The shortest fix is telling EF Core up front that the orders will be needed:

foreach (var customer in db.Customers.Include(c => c.Orders).ToList())
{
    var total = customer.Orders.Sum(o => o.Total);
    lines.Add($"{customer.Name}: {customer.Orders.Count} zamówienia, razem {total} zł");
}

One line changed. The log now shows a single command:

SELECT "c"."Id", "c"."Name", "o"."Id", "o"."CustomerId", "o"."Total"
FROM "Customers" AS "c"
LEFT JOIN "Orders" AS "o" ON "c"."Id" = "o"."CustomerId"
ORDER BY "c"."Id"

Counter: 1. The report output is identical, character for character.

The LEFT JOIN returns 150 rows, one per order, repeating the customer’s columns in each. EF Core folds them back into 50 Customer objects with populated collections. The proxies are still there, but the collection is already loaded, so reading Orders sends nothing.

Repeating one customer’s columns is cheap. Include several collections at once and the JOIN’s row count multiplies, which is what AsSplitQuery() is for. Appended after Include, it gave me exactly two queries: customers, then all their orders through an INNER JOIN. Two isn’t N+1. The count still doesn’t depend on how many customers exist, and that’s the measure that matters. At 500 customers the split version’s median landed between 16 and 19 ms, in the same range as plain Include.

A projection: fetch only what the report prints

The report doesn’t need Order objects. It needs two numbers per customer, so ask for those:

var rows = db.Customers
    .Select(c => new { c.Name, Count = c.Orders.Count, Total = c.Orders.Sum(o => o.Total) })
    .ToList();

EF Core translates the projection into one query and lets the database do the counting and summing:

SELECT "c"."Name", (
    SELECT COUNT(*)
    FROM "Orders" AS "o"
    WHERE "c"."Id" = "o"."CustomerId") AS "Count", (
    SELECT COALESCE(ef_sum("o0"."Total"), '0.0')
    FROM "Orders" AS "o0"
    WHERE "c"."Id" = "o0"."CustomerId") AS "Total"
FROM "Customers" AS "c"

ef_sum is a function the SQLite provider registers for summing decimal, since SQLite has no decimal type of its own.

One query, 50 rows instead of 150, nothing to track. The median stayed under a millisecond in every series at 50 customers and around 2 ms at 500. Same query count as Include, roughly a tenth of the time. Under Include, most of the time doesn’t go to the database at all. It goes to building 200 tracked entities and their proxies, and the projection builds none.

There’s a middle option: Include plus AsNoTracking(). Entities still get built, but the context doesn’t track them. In my runs that cut the median about in half compared with plain Include: from about 8 to a little over 3 ms at 50 customers, and from 32 to 16 ms at 500 in the same series.

Which one to pick depends on what happens next. If the code modifies orders and saves them with SaveChanges, you need tracked entities and Include is right. If it only reads them to display something, the projection wins.

The same problem in an API endpoint

A foreach is easy to spot in a code review. It’s harder when you don’t write the loop at all. An endpoint that returns a list of entities hands it to the JSON serializer, and the serializer walks every property of every object, Orders included.

I checked that with the same proxy-enabled context: customers loaded without Include, passed to JsonSerializer.Serialize. With default options it throws:

System.Text.Json.JsonException: A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 64. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path: $.Orders.Customer.Orders.Customer.Orders...

Order has a Customer and Customer has Orders, so the serializer bounces between them while the proxies load more on the way. The counter read two queries by the time it threw.

The usual reaction is setting ReferenceHandler.IgnoreCycles. The exception goes away, the response starts with [{"Id":1,"Name":"Klient 1","Orders":[{"Id":1,"Total":11.0,..., and the counter reads 51. Same N+1 as the report, except the loop now lives in System.Text.Json, which you don’t read.

So an endpoint shouldn’t return entities. Return objects built with a Select projection like the one above. They have no navigation properties, so there’s nothing for the serializer to lazy-load.

Turning lazy loading off isn’t a fix

It’s tempting to delete UseLazyLoadingProxies() and call it done. I checked what the original loop does then, without Include. One query, no error, and this output:

Klient 1: 0 zamówienia, razem 0 zł

Zero orders, zero total. Without lazy loading an unloaded collection is empty, and the report happily sums nothing. You trade slow code for fast code that reports wrong numbers, which is a worse deal. The fix is saying which data you need, through Include or a projection.

Catching it before production does

The query counter I used for this post is a few lines and works in a test too. It derives from DbCommandInterceptor, overrides ReaderExecuted and bumps a Count field. The test registers it with AddInterceptors, runs the report and asserts Assert.Equal(1, counter.Count).

That test guards something a first unit test that compares output never would. The 51-query report produces correct output, so an output test passes. The query-count test fails.

The other way needs no code. Turn on LogTo in development and read the log when a page or endpoint feels slow. A run of identical queries that differ only in @p is the signature of N+1. By default EF Core hides parameter values, so each line looks the same:

Executed DbCommand (0ms) [Parameters=[@p='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']

Add EnableSensitiveDataLogging() to the context options and the same log shows @p='1', @p='2' and so on, up to @p='50' in the last query. Then you can count the N straight off the log. Keep that option local, since it writes query data into your logs, and in a real app that data is your customers’.

A debugger won’t show any of this, because every step of the loop looks fine. It only shows up in the log, which makes it a good companion to when a debugger beats print statements.

Every navigation property read inside a loop is a potential query. Inside a loop, read only what you loaded beforehand.

EF Core, loading related data and projections are part of the ASP.NET Core course; the chapters are listed in the curriculum.

Topics: EF Core.NETErrors and messages

The download has started

DevJourney_2.1.1_x64-setup.exe · 2.1.1

If your system warns you, and how to check the file

If Windows warns you

"Windows protected your PC" is not a virus detection. SmartScreen trusts the certificate an installer is signed with, and this one is still earning its reputation through a download count.

In the warning: More info → Run anyway.

Confirm it yourself

Check this download on VirusTotal ↗

Or in PowerShell, where you downloaded it:

Get-FileHash .\DevJourney_2.1.1_x64-setup.exe

Should print:

2e441de05e02ae32caad96bdca86f9ca0ff59e01cbc8b289a1f6f783e4f7bc46