EF Core Performance - N+1, Cartesian Explosion and How to Fix Both

Introduction
You send a single HTTP request. Your app fetches 500 orders. Looks fine. But in the background, EF Core just fired 1501 SQL queries against your database - and the response took 7.5 seconds.
This is the N+1 problem, and it's one of the most common performance killers in EF Core applications. The frustrating part is that the code causing it looks completely reasonable. No obvious red flags, no suspicious loops - just navigational properties being accessed like normal.
In this article you'll see exactly why it happens, what Cartesian explosion is, and three different approaches to loading related data - each with different trade-offs depending on your situation.
🎬 Watch the full video here:
🐢 The N+1 Problem
Here's the endpoint that triggered 1501 queries:
group.MapGet("/nplusone", async (AppDbContext db) =>
{
var orders = await db.Orders.ToListAsync();
var results = new List<OrderResult>();
foreach (var order in orders)
{
results.Add(new OrderResult(
order.Id,
order.Customer.Name,
[.. order.Items.Select(x => x.ProductName)],
[.. order.Tags.Select(x => x.Name)]));
}
return Results.Ok(results);
});
One call to db.Orders.ToListAsync() fetches the orders. Then in the foreach, accessing order.Customer, order.Items, and order.Tags fires a separate SQL query for each navigational property on each order. With 500 orders and 3 navigational properties each:
1 (initial query) + 3 * 500 = 1501 SQL queries
The root cause is UseLazyLoadingProxies() being enabled in the DbContext configuration - combined with the virtual keyword on navigational properties. EF Core intercepts every property access and goes back to the database silently.
// What enables lazy loading - remove this
builder.Services.AddDbContext<AppDbContext>(options =>
options
.UseSqlServer(builder.Configuration.GetConnectionString("Default"))
.UseLazyLoadingProxies() // the culprit
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging());
Microsoft disables lazy loading by default for exactly this reason. Remove UseLazyLoadingProxies() and drop the virtual keywords from all navigational properties. After that, accessing them without explicitly loading the data throws a NullReferenceException - which is the right behavior. It forces you to be intentional.
The correct DbContext setup:
builder.Services.AddDbContext<AppDbContext>(options =>
options
.UseSqlServer(builder.Configuration.GetConnectionString("Default"))
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging());
Key points:
- Lazy loading is disabled by default in EF Core - do not re-enable it without understanding the query cost
- The
virtualkeyword on navigational properties is what allows lazy loading proxies to intercept access - A
NullReferenceExceptionafter removing lazy loading is expected - it means you now control what gets loaded
Fix 1: Projections
Projections let you select only the data you actually need. EF Core translates the Select into SQL joins and aggregate functions server-side.
group.MapGet("/projection", async (AppDbContext db) =>
{
var results = await db.Orders
.Select(o => new
{
o.Id,
CustomerName = o.Customer.Name,
o.Total,
ItemCount = o.Items.Count
})
.ToListAsync();
return Results.Ok(results);
});
This produces a single SQL query with a JOIN and a COUNT aggregate. EF Core also skips change tracking automatically when projecting to anonymous types - no need to call AsNoTracking().
Result: ~230ms for 500 orders.
When Projections Break Down
The moment you include collections in the projection, things change:
group.MapGet("/projection-2", async (AppDbContext db) =>
{
var results = await db.Orders
.Select(o => new
{
o.Id,
CustomerName = o.Customer.Name,
Items = o.Items.Select(x => x.ProductName),
Tags = o.Tags.Select(x => x.Name)
})
.ToListAsync();
return Results.Ok(results);
});
Including both Items and Tags as collections forces EF Core to perform joins that multiply rows. Every order row gets duplicated for every item, and again for every tag. This is Cartesian explosion - and it caused a 15 second response time in testing.
✅ Use projections when:
- Accessing a single navigational property
- Using aggregate functions (COUNT, SUM, AVG)
- Projecting scalar values only
❌ Avoid projections when:
- Including multiple collections in the projected shape
Fix 2: Eager Loading
Eager loading uses Include() to tell EF Core what to load upfront. You get the full object graph and map it yourself:
group.MapGet("/eager", async (AppDbContext db) =>
{
var orders = await db.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.Include(o => o.Tags)
.AsNoTracking()
.ToListAsync();
var results = orders.Select(o => new OrderResult(
o.Id,
o.Customer.Name,
[.. o.Items.Select(x => x.ProductName)],
[.. o.Tags.Select(x => x.Name)])).ToList();
return Results.Ok(results);
});
AsNoTracking() is essential for read-only endpoints - without it, EF Core tracks every returned entity, adding memory and CPU overhead that serves no purpose here.
The problem is the same as with multiple collection projections. Multiple Include() calls on collections produce SQL JOINs that multiply result rows. With three collections the database returns every combination of order, item, and tag as a separate row. In testing this endpoint timed out completely - the Cartesian explosion was severe enough that the API could not return a response at all.
✅ Use eager loading when:
- Including a single collection
- You need the full entity, not just specific columns
❌ Avoid eager loading when:
- Including multiple collections simultaneously - Cartesian explosion will occur
⚡ Fix 3: AsSplitQuery
AsSplitQuery() solves Cartesian explosion when you need multiple collection includes. Instead of one JOIN-heavy query that multiplies rows, EF Core fires separate SQL queries per collection and assembles the result in memory.
group.MapGet("/split", async (AppDbContext db) =>
{
var orders = await db.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.Include(o => o.Tags)
.AsNoTracking()
.AsSplitQuery()
.ToListAsync();
var results = orders.Select(o => new OrderResult(
o.Id,
o.Customer.Name,
[.. o.Items.Select(x => x.ProductName)],
[.. o.Tags.Select(x => x.Name)])).ToList();
return Results.Ok(results);
});
One method call added to the eager loading endpoint. That's it.
Result: ~1 second on the first request, ~700ms on the second. No timeout. No Cartesian explosion.
Trade-offs to Know
AsSplitQuery is not free:
- High database latency multiplies the cost - each split query is a separate round-trip, so latency stacks up
- No consistency guarantee - if data changes between the split queries executing, different parts of your result could reflect different database states
✅ Use AsSplitQuery when:
- Including multiple collections that would otherwise cause Cartesian explosion
- Database latency is low
- You can tolerate slight inconsistency between collections
❌ Avoid AsSplitQuery when:
- Database latency is high and you have strict performance budgets
- You need guaranteed snapshot consistency across all included collections
Key Takeaways
- Lazy loading is disabled by default in EF Core for good reason - never re-enable it in production without understanding the 1+N query cost.
- Use projections for scalar properties and aggregate functions, but avoid projecting into multiple collections or you will hit Cartesian explosion just as fast as with eager loading.
- Multiple
Include()calls on collections produce Cartesian explosion - the result set grows exponentially and can cause full request timeouts. AsSplitQuery()eliminates Cartesian explosion by splitting includes into separate queries - one method call away from fixing a timeout.- Always add
AsNoTracking()for read-only queries - it removes change tracking overhead that serves no purpose when you are not updating data.