← all posts

Slaying the EF Core Cartesian Explosion with AsSplitQuery()

Gordon Beeming
Gordon Beeming

You've designed a clean domain model with DDD principles, an aggregate root, and Entity Framework Core loading all the related child entities. Then you run it and loading a single record takes 30 seconds.

That's exactly where I was recently. After a significant refactoring, our main query performance fell off a cliff. I was not looking forward to the debugging session.

I jumped on a call with my colleague, Daniel Mackay, to show him the issue. A couple of minutes in, he asked: "Have you tried .AsSplitQuery()?"

That one question led to the fix. The underlying problem is a classic performance issue known as a Cartesian Explosion, and EF Core has a straightforward solution for it.

#The scenario: a rich domain model

Let's imagine a simplified e-commerce domain. We have an Order as our aggregate root. An Order contains a list of OrderItems and also has a ShipmentHistory to track its journey.

Here's what our simple entities might look like:

Entity classes
// The Aggregate Root
public class Order
{
    public Guid Id { get; set; }
    public DateTimeOffset OrderDate { get; set; }
    public string CustomerName { get; set; } = string.Empty;

    // Child Collections
    public List<OrderItem> Items { get; set; } = [];
    public List<ShipmentHistory> History { get; set; } = [];
}

// A child entity
public class OrderItem
{
    public Guid Id { get; set; }
    public Guid OrderId { get; set; }
    public string Sku { get; set; } = string.Empty;
    public int Quantity { get; set; }
}

// Another child entity
public class ShipmentHistory
{
    public Guid Id { get; set; }
    public Guid OrderId { get; set; }
    public DateTimeOffset Timestamp { get; set; }
    public string Status { get; set; } = string.Empty;
}

Our goal is simple: we want to load a single Order from the database and include both its Items and its History so we can display a full summary. The EF Core query looks clean and intuitive:

Default single query
// The query to load our aggregate
var order = await dbContext.Orders
    .Include(o => o.Items)
    .Include(o => o.History)
    .FirstOrDefaultAsync(o => o.Id == someOrderId);

This code works. It just doesn't scale the way you'd hope.

#The problem: the "Cartesian Explosion"

When Entity Framework sees multiple .Include() calls for collections at the same level, it generates a single, massive SQL query with multiple LEFT JOINs to get all the data in one go.

While this sounds efficient, it creates a huge, duplicated result set. If an order has 10 items and 5 history entries, the database doesn't return 10 + 5 = 15 child rows. It returns 10 * 5 = 50 rows, repeating the order and item data for every history entry.

In the real scenario that prompted this post, we had ~150 section changes and ~880 question changes. That produced a query returning over 133,000 rows to load a single object. Slow for the database to generate, heavy on network bandwidth, and then EF Core has to churn through all those duplicates on the client side too.

#The solution: .AsSplitQuery()

The EF Core team knew about this problem. The fix is .AsSplitQuery(). Adding this one method changes EF Core's entire strategy.

Here's the fix:

AsSplitQuery fix
// The one-line fix that changes everything
var order = await dbContext.Orders
    .Include(o => o.Items)
    .Include(o => o.History)
    .AsSplitQuery() // <-- That's it!
    .FirstOrDefaultAsync(o => o.Id == someOrderId);

Instead of generating one giant query, EF Core now generates multiple, smaller queries: one for the root Order, a second for its Items, and a third for its History. The total number of rows returned from the database drops from over 133,000 to just over 1,000—a 99% reduction. The result is a query that runs almost instantly.

#When to use it

It's not the default for a reason. Split queries involve multiple round-trips to the database, and the results aren't guaranteed to be transactionally consistent across those separate queries. For read-only scenarios that's rarely a problem, but it's worth knowing.

A good rule of thumb: use .AsSplitQuery() when you're including more than one one-to-many collection in the same query. For single includes, the default is usually fine.

#Conclusion

The performance gain was significant, but honestly the thing that stuck with me was how fast the fix was to apply. Because our application uses the Specification pattern, we had 3 or 4 well-defined entry points for querying this aggregate root. Adding .AsSplitQuery(), running the tests, and verifying the UI took less than two minutes total.

Good architecture pays off in ways that aren't obvious until you hit a bottleneck like this. Clear data access patterns mean you're not hunting down 30 call sites when something needs to change.

So if your rich domain model feels sluggish, check your includes. .AsSplitQuery() might be the only change you need.

Gordon Beeming
Gordon Beeming

Father • Husband • Triathlete • SSW Solution Architect

Related posts