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:
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.
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().
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:
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:
var products = await context.Products
.AsNoTracking()
.Where(p => p.Price > 10)
.ToListAsync();
For read-heavy contexts, you can set this as the default:
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:
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:
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
- Keep DbContext lifetimes short. The more entities tracked, the slower
DetectChanges()becomes. In web applications, scope your context to a single request. - Use
AsNoTracking()for read-only queries. This is the single biggest performance improvement most applications can make. - Call
context.ChangeTracker.Clear()if reusing a context to release tracked entities and free memory. - Avoid tracking thousands of entities. If you need to process large data sets, work in batches and create a fresh context for each batch.
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.