Skip to main content

Change Tracking Deep Dive

Overview

Change tracking is how EF Core monitors changes to entities and determines what database operations to perform when SaveChanges is called.

Entity States

Every entity tracked by DbContext has a state:

public enum EntityState
{
Detached, // Not tracked by context
Unchanged, // Tracked, no changes
Added, // New entity, will be inserted
Modified, // Tracked, has changes, will be updated
Deleted // Marked for deletion, will be deleted
}

Viewing Entity State

var product = new Product { Name = "Laptop", Price = 999 };
Console.WriteLine(context.Entry(product).State); // Detached

context.Products.Add(product);
Console.WriteLine(context.Entry(product).State); // Added

await context.SaveChangesAsync();
Console.WriteLine(context.Entry(product).State); // Unchanged

product.Price = 899;
Console.WriteLine(context.Entry(product).State); // Modified

context.Products.Remove(product);
Console.WriteLine(context.Entry(product).State); // Deleted

State Transitions

// Detached → Added
var product = new Product { Name = "Mouse" };
context.Products.Add(product);

// Unchanged → Modified (automatic detection)
var existing = await context.Products.FindAsync(1);
existing.Price = 99; // State becomes Modified

// Unchanged → Deleted
context.Products.Remove(existing);

// Any State → Detached
context.Entry(product).State = EntityState.Detached;

ChangeTracker API

// Get all tracked entities
var entries = context.ChangeTracker.Entries();

// Get entities in specific state
var modified = context.ChangeTracker.Entries()
.Where(e => e.State == EntityState.Modified);

// Check if any changes
bool hasChanges = context.ChangeTracker.HasChanges();

// Manually detect changes
context.ChangeTracker.DetectChanges();

// Get modified properties
var entry = context.Entry(product);
var modifiedProps = entry.Properties
.Where(p => p.IsModified)
.Select(p => p.Metadata.Name);

Tracking vs NoTracking

// WITH TRACKING (default)
var products = await context.Products.ToListAsync();
// - ChangeTracker monitors all entities
// - Can detect changes and update
// - Uses more memory
// - 20-30% slower

products[0].Price = 99;
await context.SaveChangesAsync(); // Works! Change detected

// WITHOUT TRACKING
var products = await context.Products
.AsNoTracking()
.ToListAsync();
// - Not monitored by ChangeTracker
// - Read-only scenarios
// - Less memory
// - 20-30% faster

products[0].Price = 99;
await context.SaveChangesAsync(); // Nothing happens! Not tracked

Disconnected Entities

Scenario: Web API receives updated entity

// Client sends: { "Id": 1, "Name": "Updated", "Price": 99 }
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, Product product)
{
// Option 1: Update (assumes all properties changed)
context.Products.Update(product);
// State: Modified for ALL properties

// Option 2: Attach + set state
context.Products.Attach(product);
context.Entry(product).State = EntityState.Modified;

// Option 3: Attach + mark specific properties
context.Products.Attach(product);
context.Entry(product).Property(p => p.Name).IsModified = true;
context.Entry(product).Property(p => p.Price).IsModified = true;

// Option 4: Load and update (safest)
var existing = await context.Products.FindAsync(id);
if (existing == null) return NotFound();

existing.Name = product.Name;
existing.Price = product.Price;
// Only changed properties marked as modified

await context.SaveChangesAsync();
return Ok();
}

Attach vs Update

var product = new Product { Id = 1, Name = "Updated", Price = 99 };

// Attach: State = Unchanged
context.Attach(product);
// No update will happen unless you modify it

// Update: State = Modified
context.Update(product);
// All properties will be updated in database

// AddOrUpdate pattern
if (product.Id == 0)
context.Add(product);
else
context.Update(product);

Auto-Detect Changes

// Enabled by default
context.ChangeTracker.AutoDetectChangesEnabled = true;

var product = await context.Products.FindAsync(1);
product.Price = 99;
// DetectChanges() called automatically on SaveChanges

// Disable for performance (bulk operations)
context.ChangeTracker.AutoDetectChangesEnabled = false;

foreach (var product in products)
{
product.Price *= 1.1m;
}

context.ChangeTracker.DetectChanges(); // Manual call
await context.SaveChangesAsync();

Tracking Graph of Entities

// Adding blog with posts
var blog = new Blog
{
Name = "My Blog",
Posts = new List<Post>
{
new Post { Title = "Post 1" },
new Post { Title = "Post 2" }
}
};

context.Blogs.Add(blog);
// All entities (blog + 2 posts) are now in Added state

await context.SaveChangesAsync();
// All are now Unchanged

Change Detection Strategies

// Strategy 1: Snapshot change tracking (default)
// EF creates copy of entity, compares on SaveChanges
var product = await context.Products.FindAsync(1);
product.Price = 99; // Change detected by comparing snapshots

// Strategy 2: Notification entities (advanced)
public class Product : INotifyPropertyChanged
{
private decimal _price;

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

public event PropertyChangedEventHandler PropertyChanged;
}

Querying Local Data

// Add products to context
context.Products.Add(new Product { Name = "Mouse", Price = 29 });
context.Products.Add(new Product { Name = "Keyboard", Price = 79 });

// Query tracked entities (no database call)
var local = context.Products.Local
.Where(p => p.Price < 50)
.ToList();

// Observable collection for UI binding
var observable = context.Products.Local.ToObservableCollection();

Debugging Change Tracking

// View debug information
var debugView = context.ChangeTracker.DebugView.LongView;
Console.WriteLine(debugView);

// Output shows:
// Product {Id: 1} Modified
// Id: 1 Unchanged
// Name: 'Laptop' Modified Originally 'Mouse'
// Price: 999 Modified Originally 799

Best Practices

✅ DO

// Use AsNoTracking for read-only queries
var products = await context.Products
.AsNoTracking()
.ToListAsync();

// Disable auto-detect for bulk operations
context.ChangeTracker.AutoDetectChangesEnabled = false;
foreach (var item in items)
item.Process();
context.ChangeTracker.DetectChanges();

// Use Find for single entities (checks local first)
var product = await context.Products.FindAsync(id);

❌ DON'T

// Don't track unnecessarily
var report = await context.Orders
.Include(o => o.Items)
.ToListAsync(); // Tracked but never modified!

// Don't call DetectChanges in loops
foreach (var item in items)
{
item.Process();
context.ChangeTracker.DetectChanges(); // SLOW!
}

// Don't keep context alive too long
using var context = new AppDbContext();
// ... lots of operations, many tracked entities
// Memory leak!

Interview Questions

Q: Explain the different entity states in EF Core.

A:

  • Detached: Not tracked by context
  • Unchanged: Tracked, no changes detected
  • Added: New entity, will be inserted on SaveChanges
  • Modified: Existing entity with changes, will be updated
  • Deleted: Marked for deletion, will be deleted on SaveChanges

Q: What's the difference between Attach and Update?

A: Attach sets state to Unchanged (no update unless you modify), Update sets state to Modified (all properties will be updated).

Q: When should you disable AutoDetectChanges?

A: For bulk operations where you're modifying many entities. Disable it, make all changes, then manually call DetectChanges once before SaveChanges.

Q: What's the performance impact of change tracking?

A: Tracking adds 20-30% overhead for memory and CPU. Use AsNoTracking for read-only queries to eliminate this cost.

Additional Resources