Bug: ComplexProperty + ToJson() — SaveChangesAsync throws InvalidOperationException "ordinal '-1' is invalid" when nested sub-collection grows
Description
When an entity has 2+ ComplexProperty columns mapped with ToJson(), and the JSON structure within one column contains a collection of items where each item has 2+ List<T> sub-collection properties, growing one of those sub-collections (adding items between load and save) causes SaveChangesAsync to throw:
System.InvalidOperationException: Complex entry original ordinal '-1' is invalid for property
'XWidget.XDeepData.XMiddleData[]XDeepItem[]XInnerEntry.Inner' as it's outside of the collection of length '1'.
Regression?
This appears to have been introduced around EF Core 10. Related to #37724 (which fixed a different ordinal scenario involving state transitions), but our scenario is distinct — no Detached→Modified transitions are involved.
Trigger conditions (all must hold)
- Entity has 2+
ComplexProperty(x => x.Prop, p => p.ToJson()) columns
- One JSON column has a nested object (wrapper) containing a
List<T> collection
- Each item in that collection defines 2+
List<T> sub-collection properties
- Between load and save, one of those sub-collections grows (more items than originally loaded)
Production scenario
This occurs with an "upsert" pattern: load a tracked entity from the DB, map new incoming data onto it (via Mapperly/AutoMapper/manual property assignment), then call SaveChangesAsync. If the new data has more items in a nested sub-collection than what was originally loaded, the change tracker's ordinal calculation fails.
Steps to reproduce
Full standalone repro project attached as ZIP. Here's the self-contained code (also works as a console app): HERE
Program.cs — full repro
using Microsoft.EntityFrameworkCore;
using Testcontainers.PostgreSql;
var postgres = new PostgreSqlBuilder("postgres:17").Build();
await postgres.StartAsync();
Console.WriteLine($"PostgreSQL started. EF Core version: {typeof(DbContext).Assembly.GetName().Version}");
await using var context = new XDbContext(
new DbContextOptionsBuilder<XDbContext>()
.UseNpgsql(postgres.GetConnectionString())
.Options);
var connection = context.Database.GetDbConnection();
await connection.OpenAsync();
using (var cmd = connection.CreateCommand())
{
cmd.CommandText = """
CREATE TABLE "XWidgets" (
"Id" uuid NOT NULL PRIMARY KEY,
"Flat" jsonb NOT NULL DEFAULT '{}',
"Deep" jsonb NOT NULL DEFAULT '{}'
)
""";
await cmd.ExecuteNonQueryAsync();
}
// Step 1: Insert entity with nested JSON collections
var widgetId = Guid.NewGuid();
context.XWidgets.Add(new XWidget
{
Id = widgetId,
Flat = new XFlatData { Value = "active" },
Deep = new XDeepData
{
Middle = new XMiddleData
{
PropA = "section-1",
PropB = "initial",
Items =
[
new XDeepItem
{
F1 = "item-1", F2 = "code-A", F3 = "v1",
Inner = [new XInnerEntry { Text = "alice@example.com", Flag = true }],
Others = [new XSecondEntry { Data = "note-1", Active = true }],
}
],
},
},
});
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
// Step 2: Load tracked entity
var tracked = await context.XWidgets.SingleAsync(w => w.Id == widgetId);
// Step 3: Replace Deep JSON — Inner grows from 1 to 2 items
tracked.Deep = new XDeepData
{
Middle = new XMiddleData
{
PropA = "section-1",
PropB = "updated",
Items =
[
new XDeepItem
{
F1 = "item-1", F2 = "code-A", F3 = "v2",
Inner =
[
new XInnerEntry { Text = "alice@example.com", Flag = true },
new XInnerEntry { Text = "alice-work@example.com", Flag = false }, // ← GREW from 1 to 2
],
Others = [new XSecondEntry { Data = "note-1", Active = true }],
}
],
},
};
// Step 4: SaveChangesAsync — CRASHES
try
{
await context.SaveChangesAsync();
Console.WriteLine("SUCCESS — no crash.");
}
catch (InvalidOperationException ex) when (ex.Message.Contains("ordinal"))
{
Console.WriteLine($"BUG REPRODUCED: {ex.Message}");
}
finally
{
await postgres.StopAsync();
}
// ═══ Model ═══
public class XWidget
{
public Guid Id { get; set; }
public XFlatData Flat { get; set; } = new();
public XDeepData Deep { get; set; } = new();
}
public class XFlatData
{
public string Value { get; set; } = "";
}
public class XDeepData
{
public XMiddleData Middle { get; set; } = new();
}
public class XMiddleData
{
public string PropA { get; set; } = "";
public string PropB { get; set; } = "";
public List<XDeepItem> Items { get; set; } = [];
}
public class XDeepItem
{
public string F1 { get; set; } = "";
public string F2 { get; set; } = "";
public string F3 { get; set; } = "";
public List<XInnerEntry> Inner { get; set; } = [];
public List<XSecondEntry> Others { get; set; } = [];
}
public class XInnerEntry
{
public string Text { get; set; } = "";
public bool Flag { get; set; }
}
public class XSecondEntry
{
public string Data { get; set; } = "";
public bool Active { get; set; }
}
public class XDbContext(DbContextOptions<XDbContext> options) : DbContext(options)
{
public DbSet<XWidget> XWidgets { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<XWidget>(builder =>
{
builder.HasKey(e => e.Id);
builder.ComplexProperty(e => e.Flat, s => s.ToJson());
builder.ComplexProperty(e => e.Deep, s => s.ToJson());
});
}
}
Expected behavior
SaveChangesAsync should successfully persist the updated JSON column with the grown sub-collection.
Actual behavior
SaveChangesAsync throws:
System.InvalidOperationException: Complex entry original ordinal '-1' is invalid for property
'XWidget.XDeepData.XMiddleData[]XDeepItem[]XInnerEntry.Inner' as it's outside of the collection of length '1'.
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntryBase.InternalComplexCollectionEntry.ValidateOrdinal(InternalComplexEntry entry, Boolean original, List`1 entries)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntryBase.InternalComplexCollectionEntry.ValidateOrdinal(InternalComplexEntry entry, Boolean original)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntryBase.ValidateOrdinal(InternalComplexEntry entry, Boolean original)
Environment
- EF Core: 10.0.7
- Npgsql.EntityFrameworkCore.PostgreSQL: 10.0.1
- Database: PostgreSQL 17
- OS: Windows 11 / .NET 10.0
- Target framework: net10.0
Notes
- The bug requires at least 2 JSON columns on the entity — a single
ComplexProperty + ToJson() column does NOT trigger the issue.
- The item in the outer collection must have 2+
List<T> properties defined in its class — having only a single sub-collection does not crash.
- The sibling sub-collection (
Others) can be completely empty ([]) — what matters is that the type definition has 2+ collection properties.
- Shrinking a collection or keeping it the same size does not crash — only growth triggers the bug.
- This reproduces regardless of whether the JSON column is replaced via full property assignment or mutated in-place.
Bug: ComplexProperty + ToJson() — SaveChangesAsync throws InvalidOperationException "ordinal '-1' is invalid" when nested sub-collection grows
Description
When an entity has 2+
ComplexPropertycolumns mapped withToJson(), and the JSON structure within one column contains a collection of items where each item has 2+List<T>sub-collection properties, growing one of those sub-collections (adding items between load and save) causesSaveChangesAsyncto throw:Regression?
This appears to have been introduced around EF Core 10. Related to #37724 (which fixed a different ordinal scenario involving state transitions), but our scenario is distinct — no
Detached→Modifiedtransitions are involved.Trigger conditions (all must hold)
ComplexProperty(x => x.Prop, p => p.ToJson())columnsList<T>collectionList<T>sub-collection propertiesProduction scenario
This occurs with an "upsert" pattern: load a tracked entity from the DB, map new incoming data onto it (via Mapperly/AutoMapper/manual property assignment), then call
SaveChangesAsync. If the new data has more items in a nested sub-collection than what was originally loaded, the change tracker's ordinal calculation fails.Steps to reproduce
Full standalone repro project attached as ZIP. Here's the self-contained code (also works as a console app): HERE
Program.cs — full repro
Expected behavior
SaveChangesAsyncshould successfully persist the updated JSON column with the grown sub-collection.Actual behavior
SaveChangesAsyncthrows:Environment
Notes
ComplexProperty + ToJson()column does NOT trigger the issue.List<T>properties defined in its class — having only a single sub-collection does not crash.Others) can be completely empty ([]) — what matters is that the type definition has 2+ collection properties.