Repository navigation
Description
When materialising entities via a query with split querying enabled, it's possible to encounter missing child entities purely by concurrently inserting unrelated data into the same tables. This can happen even when there have been no changes to the affected parent/child rows and ordering of the returned rows is completely stable. EF runs the expected queries and it does appear to successfully retrieve the child entities from the database, but it discards them.
Minimal consistent repro that sneakily inserts an unrelated record in between executing the first and second split queries (.NET 8.0, EF Core v8.0.5, SQL Server):
Program.cs:
using Microsoft.EntityFrameworkCore;
namespace EFTest;
public class Widget
{
public Widget(int id, IEnumerable<Child> children)
{
Id = id;
Children = children.ToHashSet();
}
// EF calls this when materialising the Widget
private Widget(int id)
{
Id = id;
// Strategically add another Widget between loading Widget 1 and loading its Children
using var dbContext = new MyDbContext();
dbContext.Widgets.Add(new(id: 2, children: [new("New")]));
dbContext.SaveChanges();
}
public int Id { get; private init; }
public HashSet<Child> Children { get; private init; } = [];
public override string ToString() =>
$"Widget {Id} with children: [{string.Join(',', Children.Select(c => c.Name))}]";
}
public record Child(string Name)
{
public int WidgetId { get; private set; }
}
public class MyDbContext() : DbContext(new DbContextOptionsBuilder().UseSqlServer(ConnectionString).Options)
{
private const string ConnectionString = "<SQL Server connection string goes here>";
public DbSet<Widget> Widgets { get; private init; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Widget>(e =>
{
e.Property(widget => widget.Id).ValueGeneratedNever();
e.OwnsMany<Child>(widget => widget.Children);
});
}
}
public static class Program
{
public static async Task Main()
{
await using (var dbContext = new MyDbContext())
{
await dbContext.Database.EnsureCreatedAsync();
await dbContext.Widgets.ExecuteDeleteAsync();
dbContext.Widgets.Add(new(id: 1, children: [new("A"), new("B"), new("C")]));
await dbContext.SaveChangesAsync();
}
await using (var dbContext = new MyDbContext())
{
var widgets = await dbContext.Widgets.AsSplitQuery().OrderByDescending(w => w.Id).ToListAsync();
Console.WriteLine(string.Join('\n', widgets));
}
}
}Add your connection string (MyDbContext.ConnectionString), generate an initial migration (dotnet ef migrations add Initial), then run the program.
Received output:
Widget 1 with children: []
Expected output:
Widget 1 with children: [A,B,C]
The repro no longer exhibits the issue if you remove the OrderByDescending(w => w.Id), but you can make the issue reappear by simply giving "Widget 2" an ID of 0. It seems that the moment the "parentless" (from the split query's perspective, anyhow) child row is encountered during enumeration, EF stops stitching together child entities with parent entities entirely. This means that depending on the order of the results you may experience child entities missing for all, or some, or no parent entities.
I acknowledge that the docs say that for split querying data consistency is not guaranteed without using serialisable/snapshotting transactions, but I found this scenario a little too unexpected and couldn't find any existing reports of this same scenario, so I thought I'd at least ask whether this is expected. The thing that makes this so unexpected is that the right data is being loaded by EF and it has enough information to stitch things together, but the presence of an additional row causes it not to do so. I'd expect these results if I was deleting "Widget 1" in between the first and second query such that EF sees the parent but no children, and I was fully willing to accept those consequences when not using serialisable/snapshotting transactions, but I didn't expect that I could make "Widget 1"s children disappear (for a single query) by merely inserting "Widget 2" at the wrong time even though "Widget 1" and all its related data was untouched the whole time.
Provider and version information
EF Core version: v8.0.5
Database provider: Microsoft.EntityFrameworkCore.SqlServer (I was able to reproduce the same behaviour with Npgsql.EntityFrameworkCore.PostgreSQL as well)
Target framework: .NET 8.0
Operating system: I've reproduced this behaviour on Windows, Linux & macOS
IDE: I've reproduced this behaviour in both Visual Studio and Rider
Activity
roji commentedon Jul 19, 2024
Sorry for taking so long to answer this.
As you noted, the moment you're doing split queries without serializable/snapshot isolation, you're exposed to all kinds of inconsistent data scenarios; this is one of the reasons split queries aren't the default loading mode. But I do agree that the behavior you're describing sounds odd and could indicate a bug in EF's materialization logic... I'll place this in the backlog for further investigation, but it's unlikely we'll get to this any time soon, as similar such inconsistencies may appear in any case (even if a bit less unexpected).
roji commentedon Aug 26, 2024
I'm not sure at this point exactly how things worked in EF Core 2.1, but what you're reporting really is the expected behavior when doing split query; without a higher isolation level (e.g. Snapshot), there are no consistency guarantees, so this sort of error can occur. As this is a concurrency issue and therefore highly sensitive to timing, my guess is that EF Core 2.1 had exactly the same behavior, but the issue simply didn't manifest.
Out of curiosity, can you please post the fully SQLs for the queries generated by both EF Core 2.1 and 8.0, so we can compare them?
roji commentedon Aug 27, 2024
@adebelyi thanks for the added detail.
This is something that isn't clear to me (but as I said, I'm unfamiliar with how EF 2.1 split queries worked). I'm probably missing something, but the principal (Menu) key must be projected in the 2nd split query which returns the children, otherwise how is EF to know which child belongs to which principal?
I do agree that a more thorough investigation is needed here at some point, and that there may be a possible bug in EF (at the very least the odd behavior originally signaled above).
danzel commentedon Nov 18, 2024
I just wrote another repro for this after it caused us a production outage.
https://github.com/torutek/EfTest
It inserts a new row between the queries of a split query (this is hooked in the
LogTomethod in EfTestContext).Data looks like
Person *-* Team -* Vehicle.Logs look like:
EF Queries out Person
EF Queries out Team
~ We race and insert a new team with a new vehicle ~
Ef Queries out Vehicle
I did a little bit of stepping through EF to see what is happening.
https://github.com/dotnet/efcore/blob/main/src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.ClientMethods.cs#L566
It looks like in here when it reads the Vehicle that is on a Team it doesn't know about it continues iterating through the teams query results looking for the matching team, but never finding a match. This causes it to discard all vehicles from that point onwards in the query.
This feels like something that should be able handled better by EF, this certainly isn't the behavior we expected based on the documentation.
This makes SplitQuery very dangerous to use without using snapshots. You risk not receiving a bunch of unrelated data from a query because new data was inserted at the same time.
erick-thompson commentedon Jan 16, 2025
Is there any indication of when this issue occurs? I would love to be able to detect how often this happens so I can the transaction tradeoff
Ahmedrz89 commentedon Mar 25, 2025
I have a similar problem where FirstOrDefaultAsync with split query return empty children
While ToListAsync return correct result
Same query and same include but different result
I have been trying to reproduce in a minimal example but it is not happening there only in our project which is too complex to share
Is there any suggestion or maybe someone had a similar problem
devony commentedon Apr 21, 2025
Has there been any further investigation of this issue? It doesn’t appear that this behavior is typical for any type of inconsistency one would expect to be intrinsic for this type of scenario (data changing in between split queries). Given that split queries generally perform better than single queries, and the workaround is to manually wrap every query in a transaction, which is impractical if one wants to enable globally, it feels like it could be worth understanding a little more clearly @roji
Please correct me if this is an inaccurate representation of the current situation.
15 remaining items
roji commentedon Mar 10, 2026
See below for a possible fix plan.
Note that a full fix here rlies on #23775, which would provide us with comparability. Note also that we have plans for a possible rewrite of our shaper architecture (to make it non-generated), it may be better to tackle this after that (unsure).
Fix plan
Fix: Split query silently drops child collections on concurrent insert (#33826)
Problem
When using
AsSplitQuery(), a concurrent insert of an unrelated parent+children between theparent query and child query execution causes all child entities for all remaining parents
to be silently dropped.
The correct data is fetched from the database — EF just discards it during materialization.
Root Cause
PopulateSplitIncludeCollectioniterates through the child data reader, which is shared acrossall parents and forward-only. When it encounters a child row whose parent identifier doesn't match
the current parent, it sets
HasNext = true(preserving the row for the next parent) and returns.The problem is that this preserved row may be an orphan — a child of a concurrently-inserted
parent that doesn't exist in the parent result set. Since no future parent will ever match it, the
cursor gets permanently stuck on the orphan row: every subsequent parent sees the same
non-matching row, sets
HasNext = true, and returns with 0 children.Example:
Solution: Ordering-Aware Orphan Skipping
Both parent and child queries are ordered by the same key (user orderings + identifier tiebreakers,
injected via
AppendOrderingInternalinSelectExpression). Parents are processed in this order.When a non-matching child row is encountered, we compare its full ordering key with the current
parent's ordering key:
parent stream. Since we've already passed it and didn't see it, this is an orphan. Skip it.
(
HasNext = true) — this is the existing behavior.This is essentially a merge-join between the parent stream and child stream, both ordered by the
same key.
No buffering. No extra queries. Near-zero overhead (comparison only on non-matching rows, which
are rare without concurrent inserts).
Why compare the full ordering key (not just identifiers)?
When the user specifies
OrderByon a non-identifier column (e.g.,.OrderBy(b => b.Name)), thefinal ordering is
ORDER BY Name ASC, Id ASC. Comparing onlyIdwould give wrong results:Comparing the full ordering key
("Charlie", 5)vs("Alpha", 10)correctly determines thatBlog 5 sorts after Blog 10, so the row should be saved, not skipped.
Ordering comparison mechanism
Use
Comparer<object>.Default.Compare()which delegates toIComparableon the boxed values.This works for all common key types (int, long, Guid, string, DateTime, etc.). For types that
don't implement
IComparable, fall back to the existing behavior (save viaHasNext = true).A separate issue (#23775) tracks adding
IComparablesupport toValueComparer; once thatlands, this code can be updated to leverage it.
Implementation
1. Add ordering key data to
SplitCollectionInfoFile:
src/EFCore.Relational/Query/SqlExpressions/SelectExpression.Helper.csAdd to the
SplitCollectionInfostruct:ParentOrderingKey(Expression) — extracts ordering key from parent data readerChildOrderingKey(Expression) — extracts ordering key from child data readerOrderingDirections(bool[]) —IsAscendingper ordering columnGenerate these alongside the existing identifier accessors in
SelectExpression.ApplyProjection,using a new
GetOrderingKeyAccessorhelper (similar toGetIdentifierAccessorbut operating onSelectExpression.Orderings). Only include the parent-level orderings (not inner collectionorderings that are appended later). Capture the parent ordering count before inner orderings are
appended to distinguish them.
2. Add ordering key data to
RelationalSplitCollectionShaperExpressionFile:
src/EFCore.Relational/Query/RelationalSplitCollectionShaperExpression.csAdd matching properties:
ParentOrderingKey(Expression)ChildOrderingKey(Expression)OrderingDirections(bool[])Update the constructor,
VisitChildren, andUpdateto include these new properties.3. Add
ParentOrderingKeytoSplitQueryCollectionContextFile:
src/EFCore.Relational/Query/Internal/SplitQueryCollectionContext.csAdd a
ParentOrderingKeyproperty (object[]) alongside the existingParentIdentifier.4. Update
InitializeSplitIncludeCollection/InitializeSplitCollectionFile:
...ShaperProcessingExpressionVisitor.ClientMethods.csThese methods are called once per parent, reading from the parent data reader.
parentOrderingKeyparameter (Func<QueryContext, DbDataReader, object[]>)SplitQueryCollectionContext.ParentOrderingKey5. Update code-gen for
InitializeSplit*callsFile:
...ShaperProcessingExpressionVisitor.csIn the expression generation code (around lines 1080-1102 for Include, 1409-1423 for Collection):
ParentOrderingKeyfromRelationalSplitCollectionShaperExpressioninto a lambdaInitializeSplit*method calls6. Update
PopulateSplitIncludeCollection[Async]/PopulateSplitCollection[Async]File:
...ShaperProcessingExpressionVisitor.ClientMethods.csAll 4 methods get two new parameters:
childOrderingKey(Func<QueryContext, DbDataReader, object[]>)orderingDirections(bool[])In the
whileloop, whenCompareIdentifiersreturnsfalse:Add a new static helper:
7. Update code-gen for
PopulateSplit*callsFile:
...ShaperProcessingExpressionVisitor.csIn the expression generation code (around lines 1107-1143 for Include, 1432-1461 for Collection):
ChildOrderingKeyfromRelationalSplitCollectionShaperExpressioninto a lambdachildOrderingKeyLambdaandorderingDirectionsas additional arguments8. Update MethodInfo references
File:
...ShaperProcessingExpressionVisitor.csThe
PopulateSplitIncludeCollectionMethodInfo,PopulateSplitIncludeCollectionAsyncMethodInfo,PopulateSplitCollectionMethodInfo,PopulateSplitCollectionAsyncMethodInfostatic fieldsreference the method signatures. These need to be updated to match the new signatures (additional
parameters).
Similarly for
InitializeSplitIncludeCollectionMethodInfoandInitializeSplitCollectionMethodInfo.9. Handle
IComparableunavailability gracefullyIn
CompareOrderingKeys, wrap theComparer<object>.Default.Compare()call to handle types thatdon't implement
IComparable. If comparison throws, return 0 (which falls through to the existingHasNext = truebehavior — no skip, same as current code).10. Add regression test
File:
test/EFCore.Relational.Specification.Tests/Query/AdHocQuerySplittingQueryTestBase.csAdd a test that:
Blog(Id=10)) with children (Posts)DbCommandInterceptorto inject a concurrent insert (e.g.,Blog(Id=1)with aPost)just before the child query executes
Blogs.Include(b => b.Posts).AsSplitQuery().ToListAsync()Add a second test variant with
OrderByDescending(b => b.Id)(the original issue reporter'sscenario).
Consider a third variant with
OrderBy(b => b.Name)(non-identifier ordering) to cover the fullordering key comparison path.
Files to Modify
SelectExpression.Helper.csSplitCollectionInfoSelectExpression.csApplyProjection; addGetOrderingKeyAccessorhelperRelationalSplitCollectionShaperExpression.csSplitQueryCollectionContext.csParentOrderingKeypropertyShaperProcessingExpressionVisitor.csShaperProcessingExpressionVisitor.ClientMethods.csAdHocQuerySplittingQueryTestBase.csPerformance Considerations
executes when
CompareIdentifiersreturnsfalse, which means a non-matching child row wasencountered. In normal operation, non-matching rows only appear at parent boundaries (which
triggers the existing
HasNext = truepath with one additional comparison call).childOrderingKeylambda is called only onnon-matching rows. The
parentOrderingKeylambda is called once per parent duringInitializeSplit*(same asparentIdentifier).(identifier columns) are already projected. User ordering columns (e.g.,
Name) may addadditional projected columns. This is a small overhead on the SQL side.
Edge Cases
and identifier are the same columns. The comparison is trivially correct.
AppendOrderingInternaldeduplicates, so DESC ispreserved. The
orderingDirectionsarray captures this correctly.(see "Why compare the full ordering key" section above).
respects the column order and direction.
IComparable:CompareOrderingKeyscatches the exception and returns 0,falling back to existing behavior (no orphan skipping).
cmp == 0with non-matching identifier: theoretically impossible (ordering key includesidentifiers as tiebreakers), but handled safely by falling through to
HasNext = true.SplitQueryCollectionContext. The fix applies independently per collection.own
PopulateSplit*call with its own ordering key. The fix applies at each level.devony commentedon May 19, 2026
@roji just wondering if this is planned for the .net 11 release timeline
roji commentedon May 20, 2026
@devony that's unclear at this point.
Fixes #33826
Fixes #33826
Handle out-of-order split include rows during concurrent inserts (#38676
docs: record why PendingModelChangesWarning is suppressed
Add migration designer chain tests and document timestamp suppression (…