EF Core 10 Primitive Collections can crash even when the same custom ValueConverter appears to work for a scalar property. The dangerous shape is specific: a non-null element enters the converter, the provider expression returns null, and the collection pipeline later throws a NullReferenceException during save or materialization.

This guide reproduces that boundary on EF Core 10.0.12 and 10.0.13, separates real nullable elements from converter-produced nulls, and gives you a safe mapping rule plus regression tests. The upstream issue is still open, so treat the workaround as an explicit model decision—not as a prediction of a future framework fix.

Recognize the EF Core 10 Primitive Collections failure

EF Core documentation states that null values normally bypass value converters. A database null remains null in the model and vice versa. That rule is useful, but it does not make it safe for a converter to manufacture null from a non-null value.

Consider a value object where zero is treated as “missing”:

public readonly record struct Quantity(int Value);

public sealed class QuantityConverter()
    : ValueConverter<Quantity?, int?>(
        quantity => quantity == null || quantity.Value.Value == 0
            ? null
            : quantity.Value.Value,
        value => value == null || value == 0
            ? null
            : new Quantity(value.Value));

The converter collapses two model states—an actual null and Quantity(0)—into the same provider null. A scalar property may appear to tolerate that mapping. A primitive collection can take a different internal path because EF must convert each element and then assemble or parse the collection value.

public sealed class Order
{
    public int Id { get; set; }
    public Quantity? Single { get; set; }
    public List<Quantity?> Lines { get; set; } = [];
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>(order =>
    {
        order.Property(x => x.Single)
            .HasConversion<QuantityConverter>();

        order.PrimitiveCollection(x => x.Lines)
            .ElementType()
            .HasConversion<QuantityConverter>();
    });
}

With that model, Single = new Quantity(0) is not enough to expose the collection bug. The failure appears when a non-null collection element converts to null, or when a stored provider value converts back to a null element.

Reproduce the write and read paths

Use a minimal relational provider so the test exercises EF Core’s relational primitive-collection conversion path. The proof for this article used an in-memory SQLite connection and created the schema once.

using var connection = new SqliteConnection("Data Source=:memory:");
connection.Open();

using (var setup = new ShopContext(connection))
{
    setup.Database.EnsureCreated();
}

Start with controls. A real null element should remain a real null; a normal non-null element should round-trip. Those controls matter because a failing test without them cannot tell you whether primitive collections are broken generally or whether your converter created the bad state.

using (var db = new ShopContext(connection))
{
    db.Add(new Order
    {
        Single = new Quantity(0),
        Lines = [new Quantity(5), null]
    });

    db.SaveChanges(); // Control: succeeds in the verified setup.
}

Now exercise the write failure:

using (var db = new ShopContext(connection))
{
    db.Add(new Order
    {
        Lines = [new Quantity(5), new Quantity(0)]
    });

    db.SaveChanges();
    // Verified result:
    // DbUpdateException
    //   InnerException: NullReferenceException
}

The collection is non-null and both elements are non-null. The difference is that Quantity(0) becomes provider null inside the element converter.

Test materialization separately. Insert a provider-side collection that contains the sentinel value your converter maps to null, then query it:

using (var command = connection.CreateCommand())
{
    command.CommandText = """
        INSERT INTO "Orders" ("Id", "Single", "Lines")
        VALUES (101, NULL, '[5,0]')
        """;
    command.ExecuteNonQuery();
}

using var readDb = new ShopContext(connection);
var order = readDb.Orders.Single(x => x.Id == 101);
// Verified result: NullReferenceException

Keeping the write and read assertions separate is important. A workaround that only prevents new bad writes does not prove that existing provider data can be materialized safely.

Read the verified version matrix

The EF Core 10 Primitive Collections proof used .NET SDK 10.0.401 and Microsoft.EntityFrameworkCore.Sqlite. It restored and executed the same source against two EF Core patch versions.

EF Core packageScalar Quantity(0)Collection with real nullNon-null element converts to null on writeStored value converts to null on read
10.0.12PASSPASSDbUpdateException / inner NullReferenceExceptionNullReferenceException
10.0.13PASSPASSDbUpdateException / inner NullReferenceExceptionNullReferenceException

This is an observed matrix, not a claim about every EF Core version or provider. The open upstream report also includes an EF Core 11 RC1 reproduction, but this article’s execution evidence is limited to 10.0.12 and 10.0.13.

Choose explicit null semantics

The safest fix is to stop using the converter to invent null. Decide which of these two domain models you actually mean.

Domain meaningModel shapeConverter rule
Missing collection elements are validList<Quantity?>Use actual null elements for missing values; every non-null Quantity must produce a non-null provider value
Every element must contain a quantityList<Quantity>Use non-nullable model and provider types; validate forbidden values before EF conversion

The verified non-nullable workaround removes the ambiguous state:

public sealed class SafeQuantityConverter()
    : ValueConverter<Quantity, int>(
        quantity => quantity.Value,
        value => new Quantity(value));

public sealed class SafeOrder
{
    public int Id { get; set; }
    public List<Quantity> Lines { get; set; } = [];
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<SafeOrder>()
        .PrimitiveCollection(x => x.Lines)
        .ElementType()
        .HasConversion<SafeQuantityConverter>();
}

A SafeOrder containing [new Quantity(5), new Quantity(0)] saved and round-tripped on EF Core 10.0.13 in the local proof. Zero stays zero. If zero is invalid in your domain, reject it at the aggregate boundary with validation; do not silently turn it into database null inside the converter.

If the collection really allows missing elements, keep List<Quantity?> and pass actual nulls. The verified control [new Quantity(5), null] saved successfully. The essential rule remains: a non-null model value must not unexpectedly produce a null provider value.

Add regression tests before rollout

Protect all four boundaries: a normal element, a real null element when allowed, the previously dangerous sentinel, and existing provider data. Do not stop at “the application builds.”

[Fact]
public void Non_nullable_collection_round_trips_zero()
{
    using var connection = OpenConnection();
    using (var setup = new SafeShopContext(connection))
        setup.Database.EnsureCreated();

    using (var write = new SafeShopContext(connection))
    {
        write.Add(new SafeOrder
        {
            Lines = [new Quantity(5), new Quantity(0)]
        });
        write.SaveChanges();
    }

    using var read = new SafeShopContext(connection);
    var saved = read.SafeOrders.AsNoTracking().Single();

    Assert.Equal([5, 0], saved.Lines.Select(x => x.Value));
}

For nullable collections, add a separate test that writes and reads a real null element. If you are changing an established mapping, seed provider data that represents every legacy value you expect to encounter. A successful new write does not validate old rows.

Pin the package version in the test project and make the version visible in logs:

var efVersion = typeof(DbContext)
    .Assembly
    .GetName()
    .Version;

testOutput.WriteLine($"EF Core assembly: {efVersion}");

When you update EF Core, run the matrix against the current production version and the candidate version. The same discipline helps catch unrelated query changes; see the EF Core 10 Contains regression workflow for another example of comparing generated behavior before rollout.

Handle production data and upgrades

  • Inventory mappings: search for primitive collections whose element converters return nullable provider types.
  • Inspect both expressions: check whether any non-null model value becomes null and whether any non-null provider value becomes null.
  • Decide the domain meaning: distinguish an actual missing element from a sentinel such as zero, empty string, or an “unknown” enum.
  • Scan legacy data: find stored collection elements that the converter would map to null during materialization.
  • Migrate deliberately: preserve actual nulls, replace sentinels, or change the model constraint according to the chosen domain rule.
  • Test the deployed provider: this proof used SQLite to isolate EF behavior; repeat the relevant tests with your production provider and schema.
  • Canary the package update: compare current and candidate EF Core versions with the same dataset and assertions.
  • Keep rollback reversible: do not deploy a data rewrite until the previous application version can safely read the new representation—or until you have accepted that rollback boundary.

Do not “fix” the exception by catching NullReferenceException around SaveChanges. That hides a model/provider contract violation and leaves read paths or existing data unresolved. Likewise, changing only the column nullability does not guarantee that per-element conversion semantics are safe.

Know the evidence limits

The local proof used Linux x64, .NET SDK 10.0.401, SQLite, and EF Core 10.0.12/10.0.13. It demonstrates the converter and primitive-collection behavior in that scope. It does not prove SQL Server JSON serialization, every provider, Native AOT deployment, or an unreleased upstream fix.

The upstream issue is current as of October 14, 2026. Recheck its status and the EF Core release notes before removing a workaround. Even after a framework fix, retain the domain-level rule: converters should not collapse distinct non-null and null states unless every query, write, read, and migration path explicitly supports that representation.

References

Found this useful? Support more practical developer content.

Author

Practical .NET, Angular, Azure, Blazor, and AI engineering for real-world development.

Write A Comment

Ads Blocker Image Powered by Code Help Pro

Ads Blocker Detected!!!

We have detected that you are using extensions to block ads. Please support us by disabling these ads blocker.

Powered By
Best Wordpress Adblock Detecting Plugin | CHP Adblock