Skip to content

Support defaults for missing JSON members in ComplexProperty().ToJson() for schema evolution #39018

Description

@valeriob

Bug description

Feature request

When a complex type is mapped to a JSON column via ComplexProperty().ToJson(), and the stored JSON is missing a property that was added in a later version of the CLR model, EF Core currently materializes that property as null.

This happens even when the property has a CLR default value assigned via a property initializer or constructor.

This makes JSON schema evolution difficult for document-like models stored in relational databases.

For example:

  1. Version A of the application stores a JSON document without property NewProperty.
  2. Version B of the application adds NewProperty to the CLR type and initializes it to a default value.
  3. EF Core loads a row written by Version A.
  4. Since the JSON payload does not contain NewProperty, EF Core materializes it as null.
  5. The default value from the CLR model is not preserved.

This is surprising because the application model expresses a valid default value, but that value is lost during EF materialization.

Desired behavior

It would be useful if EF Core provided a way to control how missing JSON members are handled when materializing complex types mapped with ComplexProperty().ToJson().

For example, one of the following behaviors would solve this scenario:

  1. Preserve CLR property initializers / constructor defaults when a JSON member is absent.
  2. Allow per-property configuration of a default value for absent JSON members.
  3. Allow a model-level or JSON-mapping-level option for missing-member handling.
  4. Allow a materialization hook specifically for JSON complex types before nullability validation / change tracking.

The important distinction is between:

  • JSON member is present with value null
  • JSON member is absent because the row was written by an older application version

For schema evolution, an absent member should ideally be treated differently from an explicit null.

Why this matters

This is common when using JSON columns as document storage with versioned application models.

A typical deployment scenario is:

  • Version A stores documents with schema A.
  • Version B adds a new non-nullable property with a default value.
  • Existing rows are still physically stored as schema A.
  • Loading those rows through the Version B model produces partially invalid domain objects.

This creates a read hazard: application code expects the property to have its default value, but it is unexpectedly null.

It may also create a save hazard depending on nullability configuration, required properties, nested complex types, or validation logic.

The current workaround is to manually normalize the object graph after materialization, for example through ChangeTracker.Tracked, an IMaterializationInterceptor, or application-level post-load logic. However, this is error-prone and must be repeated for every JSON document type.

Another workaround is to backfill all existing JSON rows whenever a property is added, but this is not always practical for large tables, multi-tenant systems, or distributed deployments.

Example

using Microsoft.EntityFrameworkCore;

public class AppDbContext : DbContext
{
    public DbSet<Record> Records => Set<Record>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseNpgsql("Host=localhost;Database=efrepro;Username=postgres;Password=postgres");

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Record>(entity =>
        {
            entity.ToTable("records");
            entity.HasKey(x => x.Id);

            entity.ComplexProperty(x => x.Document, document =>
            {
                document.ToJson("document");
            });
        });
    }
}

public class Record
{
    public Guid Id { get; set; }

    public MyDocument Document { get; set; } = new();
}

public class MyDocument
{
    // Existing property in schema version A.
    public string Name { get; set; } = "";

    // Added in schema version B.
    // Existing JSON rows written by version A do not contain this property.
    public string NewProperty { get; set; } = "default-value";
}

Assume an existing row contains JSON written by the previous application version:

{
  "Name": "Document written by version A"
}

When the row is loaded by the newer application version, the expected materialized object would be:

record.Document.NewProperty == "default-value";

But the actual value is:

record.Document.NewProperty == null;

The CLR initializer is not preserved for the absent JSON member.

Current workaround

The workaround is to normalize the JSON document after materialization:

public class MyDocument
{
    public string Name { get; set; } = "";

    public string? NewProperty { get; set; } = "default-value";

    public void Normalize()
    {
        NewProperty ??= "default-value";
    }
}

For example:

using Microsoft.EntityFrameworkCore.Diagnostics;

public sealed class DocumentMaterializationInterceptor : IMaterializationInterceptor
{
    public object InitializedInstance(
        MaterializationInterceptionData materializationData,
        object entity)
    {
        if (entity is Record record)
        {
            record.Document?.Normalize();
        }

        return entity;
    }
}

However, this requires every application to implement its own schema-evolution/defaulting layer outside of EF Core.

Related issue

This looks related to:

That issue focuses on absent nested complex collections materializing as null instead of empty collections.

This feature request is for the more general schema-evolution case where any newly-added JSON-mapped complex type property may be absent from older stored JSON documents, and EF Core does not preserve CLR defaults during materialization.

EF Core version

EF Core version: [please fill in, e.g. 10.0.x or 11.0 preview x]

Database provider

Provider: [please fill in, e.g. Npgsql.EntityFrameworkCore.PostgreSQL / Microsoft.EntityFrameworkCore.SqlServer]

Target framework

Target framework: [please fill in, e.g. .NET 10.0 / .NET 11.0]

Your code

<See Above>

Stack traces


Verbose output


EF Core version

10

Database provider

Microsoft.EntityFrameworkCore.SqlServer

Target framework

.NET 10

Operating system

No response

IDE

No response

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions