Change Tracking Deep Dive: How EF Core Knows What Changed

Every time you call SaveChanges(), EF Core works out exactly which entities have been added, modified, or deleted — and generates the corresponding SQL. But how does it actually detect those changes? Understanding the change tracker is essential for writing efficient data access code.

Entity States

The change tracker assigns one of five states to every entity it knows about:

State Meaning
Detached Not tracked at all
Unchanged Tracked, but no modifications detected
Added New entity, will be inserted
Modified Existing entity with changed properties
Deleted Existing entity, will be removed

You can inspect an entity's state at any time:

Example.cs
var entry = context.Entry(product);
Console.WriteLine(entry.State); // e.g. EntityState.Modified

Snapshot Change Tracking

By default, EF Core uses snapshot-based change tracking. When an entity is first tracked (typically after a query), EF Core takes a snapshot of every property value. When SaveChanges() or ChangeTracker.DetectChanges() is called, EF Core compares the current values against the snapshot to find differences.

Example.cs
await using var context = new AppDbContext();

var product = await context.Products.FirstAsync(p => p.Id == 1);
// Snapshot taken: { Name = "Widget", Price = 9.99 }

product.Price = 12.99m;

// DetectChanges runs automatically inside SaveChanges
await context.SaveChangesAsync();
// EF compares current values to the snapshot, finds Price changed

This comparison happens property by property. For entities with many properties, this can become expensive if you're tracking thousands of entities simultaneously.

Notification-Based Change Tracking

For performance-critical scenarios, you can implement INotifyPropertyChanged and INotifyPropertyChanging on your entities. This tells EF Core to listen for property change events rather than scanning every property on every call to DetectChanges().

Example.cs
public class Product : INotifyPropertyChanged, INotifyPropertyChanging
{
    private decimal _price;

    public int Id { get; set; }

    public decimal Price
    {
        get => _price;
        set
        {
            if (_price != value)
            {
                PropertyChanging?.Invoke(this,
                    new PropertyChangingEventArgs(nameof(Price)));
                _price = value;
                PropertyChanged?.Invoke(this,
                    new PropertyChangedEventArgs(nameof(Price)));
            }
        }
    }

    public event PropertyChangedEventHandler? PropertyChanged;
    public event PropertyChangingEventHandler? PropertyChanging;
}

Configure it in your DbContext:

Example.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>()
        .HasChangeTrackingStrategy(ChangeTrackingStrategy.ChangingAndChangedNotifications);
}

This eliminates the need for snapshot comparisons entirely. The trade-off is more boilerplate in your entity classes.

No-Tracking Queries

If you're reading data without intending to update it, skip the change tracker altogether:

Example.cs
var products = await context.Products
    .AsNoTracking()
    .Where(p => p.Price > 10)
    .ToListAsync();

For read-heavy contexts, you can set this as the default:

ReportingDbContext.cs
public class ReportingDbContext : DbContext
{
    public ReportingDbContext(DbContextOptions<ReportingDbContext> options)
        : base(options)
    {
        ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
    }
}

No-tracking queries are measurably faster because EF Core skips identity resolution and snapshot creation. In benchmarks, no-tracking queries can be up to 40% faster for large result sets.

Identity Resolution

The change tracker also provides identity resolution. If you query the same entity twice within a single DbContext instance, you get the same object reference:

Example.cs
var product1 = await context.Products.FindAsync(1);
var product2 = await context.Products.FirstAsync(p => p.Id == 1);

Console.WriteLine(ReferenceEquals(product1, product2)); // True

With AsNoTracking(), each query returns a separate object instance. EF Core 8 introduced AsNoTrackingWithIdentityResolution() as a middle ground — no change tracking overhead, but duplicate entities in a single query are still resolved to the same instance.

Inspecting What Changed

The ChangeTracker API lets you examine pending changes before saving, which is useful for auditing:

Example.cs
foreach (var entry in context.ChangeTracker.Entries()
    .Where(e => e.State == EntityState.Modified))
{
    foreach (var prop in entry.Properties.Where(p => p.IsModified))
    {
        Console.WriteLine(
            $"{entry.Entity.GetType().Name}.{prop.Metadata.Name}: " +
            $"{prop.OriginalValue} → {prop.CurrentValue}");
    }
}

Practical Tips

Understanding EF Core's change tracker turns it from a "magic" layer into a predictable, tuneable component. Use snapshot tracking for simplicity, notification tracking for hot paths, and no-tracking for reads — and your data layer will thank you.