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.