EF Core Split Query vs Single Query for Include
Including two or more collections in one EF Core query multiplies result rows through JOINs. AsSplitQuery fixes that with extra round trips — here's when each mode wins and how to verify with logged SQL.
10 Jul 2025, 15:58 UTC

The short answer
Use a single query (the default) when your Include calls load only reference navigations or one collection. Switch to AsSplitQuery() when you include two or more collections, or one large collection alongside other data. The reason is mechanical: each included collection adds a JOIN, and multiple collection JOINs multiply the rows SQL Server has to send back. Splitting trades that row explosion for extra round trips — which is usually a good trade, but not always.
Why multiple Includes explode
When you write:
var orders = context.Orders
.Include(o => o.Lines)
.Include(o => o.StatusHistory)
.ToList();EF Core emits one SQL statement joining Orders to both Lines and StatusHistory. If an order has 20 lines and 10 history rows, that order appears in 200 result rows — every line paired with every history row. This is the cartesian explosion. The duplicated parent columns (and duplicated columns of the first collection) inflate network transfer, and EF Core must materialize and de‑duplicate all of it in memory. Add a third collection and it gets worse multiplicatively.
Reference navigations (Include(o => o.Customer)) don't cause this. A many‑to‑one join adds at most one row per parent, so including several references in a single query is cheap and stays the right choice.
What AsSplitQuery changes
Adding AsSplitQuery() tells EF Core to run each included collection as its own SQL statement:
var orders = context.Orders
.Include(o => o.Lines)
.Include(o => o.StatusHistory)
.AsSplitQuery()
.ToList();Instead of one statement returning 200 rows per order, you get three statements returning roughly 1 + 20 + 10 rows per order. Each result set is linear in the actual data size. EF Core stitches the results together by key in memory, so your object graph looks identical.
You can also make split the default for the whole context and opt hot paths back out:
options.UseSqlServer(connectionString, sql =>
sql.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));With that global setting, an individual query can force the old behavior with AsSingleQuery(). This "safe default, tune exceptions" pattern works well on teams where most developers shouldn't have to think about JOIN cardinality. Per‑query AsSplitQuery()/AsSingleQuery() always wins over the global setting.
How to verify which mode you want
Don't guess — look at the SQL and the row counts. Enable logging on the context during development:
options.LogTo(Console.WriteLine, LogLevel.Information);Run the query both ways and compare. With a single query you'll see one SELECT with multiple JOINs; with split you'll see one SELECT for the root plus one per collection. The decisive number is total rows returned: if the joined result returns far more rows than the entities you actually asked for, splitting will help. For a real decision, benchmark end‑to‑end latency and allocated memory against a dataset that resembles production — a 10‑row dev database will hide the problem entirely.
Limits and common mistakes
Split queries are not a snapshot
The separate SQL statements execute at different times. If another transaction inserts an order line between your first and second query, you'll load it; if it deletes one, you won't. Within each individual query the data is consistent, but across queries it isn't. If you need a coherent view — say, for a financial report — wrap the read in a transaction with repeatable read or serializable isolation.
Pagination can silently corrupt
Combining Skip/Take with split queries is risky under concurrent writes: rows inserted or deleted between the root query and the collection queries can shift which children attach to which page. Use a stable, unique OrderBy (not just a non‑unique date column) and consider a transaction if correctness matters.
Splitting doesn't fix one huge collection
If the problem is a single Include pulling 50,000 child rows, splitting changes nothing — you still transfer 50,000 rows. Better tools for that case:
- Filtered include:
Include(o => o.Lines.Where(l => !l.IsDeleted))— works in both modes and often shrinks the payload more than any splitting strategy. - Projection: select a DTO with only the columns you need instead of tracking full entities.
- Explicit loading: load the collection on demand via
context.Entry(order).Collection(o => o.Lines).Load()only when it's actually needed.
Latency can flip the trade‑off
Split queries mean more round trips. Against a database on another continent or behind a chatty network path, three small queries can be slower than one fat one. This is the main reason to measure rather than adopt split globally without testing your actual topology.
Version notes
Split queries became broadly available in EF Core 5; earlier versions always used the single‑query JOIN approach. The exact SQL shape varies by provider, so re‑check generated SQL after provider or major‑version upgrades. Everything above assumes EF Core 5 or later with a relational provider.
Decision checklist
- Only reference navigations or one collection included? Keep the default single query.
- Two or more collections, or one large collection with other includes? Try
AsSplitQuery()and compare logged row counts. - Need cross‑query consistency or paging under write load? Add a transaction and stable ordering.
- One enormous collection? Reach for filtered include, projection, or explicit loading instead.
- Setting a global default? Keep
AsSingleQuery()in your pocket for latency‑sensitive hot paths.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.