Skip to content

EF Core incorrectly tracks computed property as modified with wrong original value when using navigation-based property #37455

@mathiasschaemelhout

Description

@mathiasschaemelhout

Bug description

When using a computed property (CurrentOperator) in an entity (Line) that derives its value from a navigation collection (OperatorChangedEvents), EF Core's ChangeTracker incorrectly marks the property as modified with an original value of null upon materialization, even though the actual value is non-null. This leads to incorrect change tracking and prevents updates from being detected when the navigation collection changes.

Reproduction

Given the following model:

  • Line entity with a navigation collection OperatorChangedEvents
  • CurrentOperator is a computed property returning the latest OperatorName from the collection.
    This computed property is persisted to the database using the proposed workaround in issue 13316.
  • AssignOperator and ClearOperator methods add events to the collection

Steps:

  1. Add a Line and assign an operator, saving to the database.
  2. Query the Line back from the database.
  3. Observe that CurrentOperator is 'alex', but ChangeTracker marks its original value as null.
  4. Call ClearOperator() and detect changes.
  5. ChangeTracker does not mark the entity as modified, even though CurrentOperator changed.

Your code

using Microsoft.EntityFrameworkCore;

await using (var context = new AppDbContext())
{
    await context.Database.EnsureDeletedAsync();
    await context.Database.EnsureCreatedAsync();
    
    var line1 = new Line();
    var line2 = new Line();
    line1.AssignOperator("alex");
    await context.Lines.AddRangeAsync(line1, line2);
    
    // Debugging the ChangeTracker:
    // ┌────────────────────────────────────────────────────────────────────────────────┐
    // │ Line {Id: -2147482647} Added                                                   │
    // │ Line {Id: -2147482646} Added                                                   │
    // │ OperatorChangedEvent {Id: -9223372036854774807} Added FK {LineId: -2147482647} │
    // └────────────────────────────────────────────────────────────────────────────────┘
    await context.SaveChangesAsync();
    
    // Database result:
    // ┌────┬─────────────────┐
    // │ Id │ CurrentOperator │
    // ├────┼─────────────────┤
    // │  1 │ alex            │
    // │  2 │ <null>          │
    // └────┴─────────────────┘
}

await using (var context = new AppDbContext())
{
    var line1 = await context.Lines.SingleAsync(line => line.Id == 1);
    var line2 = await context.Lines.SingleAsync(line => line.Id == 2);
    
    // Debugging the ChangeTracker:
    // ┌───────────────────────────────────────────────┐
    // │ Line {Id: 1} Unchanged                        │
    // │     Id: 1 PK                                  │
    // │     CurrentOperator: 'alex' Originally <null> │ <-- For some reason, this property is marked as modified with an original value of "null" during construction
    // │   OperatorChangedEvents: [{Id: 1}]            │
    // │ Line {Id: 2} Unchanged                        │
    // │     Id: 2 PK                                  │
    // │     CurrentOperator: <null>                   │
    // │   OperatorChangedEvents: []                   │
    // │ OperatorChangedEvent {Id: 1} Unchanged        │
    // │     Id: 1 PK                                  │
    // │     LineId: 1 FK                              │
    // │     OperatorName: 'alex'                      │
    // │     TriggeredOn: '06/01/2026 10:00:43'        │
    // └───────────────────────────────────────────────┘

    line1.ClearOperator();
    line2.AssignOperator("alex");
    
    // Debugging the ChangeTracker (after detecting changes):
    // ┌────────────────────────────────────────────────────────────────┐
    // │ Line {Id: 1} Unchanged                                         │ <-- The value of CurrentOperator has changed to "null", but the entity is still marked as unchanged because EF thinks the original value was already "null"
    // │     Id: 1 PK                                                   │
    // │     CurrentOperator: <null>                                    │
    // │   OperatorChangedEvents: [{Id: 1}, {Id: -9223372036854774806}] │
    // │ Line {Id: 2} Modified                                          │
    // │     Id: 2 PK                                                   │
    // │     CurrentOperator: 'alex' Modified Originally <null>         │
    // │   OperatorChangedEvents: [{Id: -9223372036854774805}]          │
    // │ OperatorChangedEvent {Id: -9223372036854774806} Added          │
    // │     Id: -9223372036854774806 PK Temporary                      │
    // │     LineId: 1 FK                                               │
    // │     OperatorName: <null>                                       │
    // │     TriggeredOn: '06/01/2026 10:05:11'                         │
    // │ OperatorChangedEvent {Id: -9223372036854774805} Added          │
    // │     Id: -9223372036854774805 PK Temporary                      │
    // │     LineId: 2 FK                                               │
    // │     OperatorName: 'alex'                                       │
    // │     TriggeredOn: '06/01/2026 10:05:11'                         │
    // │ OperatorChangedEvent {Id: 1} Unchanged                         │
    // │     Id: 1 PK                                                   │
    // │     LineId: 1 FK                                               │
    // │     OperatorName: 'alex'                                       │
    // │     TriggeredOn: '06/01/2026 10:05:10'                         │
    // └────────────────────────────────────────────────────────────────┘
    await context.SaveChangesAsync();
    
    // Database result:
    // ┌────┬─────────────────┐
    // │ Id │ CurrentOperator │
    // ├────┼─────────────────┤
    // │  1 │ alex            │
    // │  2 │ alex            │
    // └────┴─────────────────┘
}

public class AppDbContext : DbContext
{
    public DbSet<Line> Lines { get; set; }

    private const string ConnectionString = "Server=localhost,1433;Database=Test;";
    
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => 
        optionsBuilder.UseSqlServer(ConnectionString);

    protected override void OnModelCreating(ModelBuilder modelBuilder) => 
        modelBuilder.Entity<Line>().Navigation(line => line.OperatorChangedEvents).AutoInclude();
}

public class Line
{
    public int Id { get; set; }
    public List<OperatorChangedEvent> OperatorChangedEvents { get; set; } = [];

    public string? CurrentOperator
    {
        get => OperatorChangedEvents.OrderByDescending(@event => @event.TriggeredOn).FirstOrDefault()?.OperatorName ?? null;
        private set { } // https://github.com/dotnet/efcore/issues/13316#issuecomment-421052406
    }

    public void AssignOperator(string operatorName)
    {
        var operatorEvent = new OperatorChangedEvent(operatorName);
        OperatorChangedEvents.Add(operatorEvent);
    }
    
    public void ClearOperator()
    {
        var operatorEvent = new OperatorChangedEvent(null);
        OperatorChangedEvents.Add(operatorEvent);
    }
}

public class OperatorChangedEvent 
{
    public long Id { get; set; }
    public DateTime TriggeredOn { get; set; }
    public string? OperatorName { get; set; }

    public OperatorChangedEvent(string? operatorName)
    {
        TriggeredOn = DateTime.UtcNow;
        OperatorName = operatorName;
    }
}

Stack traces


Verbose output


EF Core version

10.0.1

Database provider

Microsoft.EntityFrameworkCore.SqlServer

Target framework

.NET 10

Operating system

Windows 11

IDE

Rider

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions