EF Core change tracking
Entity states, snapshot change detection, identity resolution, and exactly which SQL SaveChanges sends.
After this lesson you can
- Name the five entity states and what SaveChanges does with each
- Predict which columns an UPDATE will contain
- Explain identity resolution and why a re-query does not undo your edits
- Choose AsNoTracking, ExecuteUpdate or Update deliberately
A DbContext is a unit of work. Every entity returned by a tracking
query is remembered by its change tracker, in one of five states:
Detached: not tracked at all.Unchanged: tracked, no differences from the database.Modified: tracked, some property differs;SaveChangessends anUPDATE.Added: new;SaveChangessends anINSERT.Deleted: removed;SaveChangessends aDELETE.
var user = await db.Users.SingleAsync(u => u.Id == 1); // Unchanged
user.Email = "new@example.com"; // still Unchanged until detected
await db.SaveChangesAsync(); // UPDATE Users SET Email = ... WHERE Id = 1
Snapshots, not setters
Plain entity classes do not tell EF Core when a property is set. Instead,
when an entity is tracked EF Core keeps a snapshot of its original
values. DetectChanges, which SaveChanges calls for you, compares
current values with the snapshot. Only properties that differ are
marked modified, and only they appear in the UPDATE. A property set
and then set back before detection is not a change at all. The model
below keeps a snapshot dictionary per entity and compares, as
DetectChanges does.
public class Tracked{ public int Id; public Dictionary<string, string> Current = new(); public Dictionary<string, string> Original = new();} public static class Solution{ static Tracked Load(int id, Dictionary<string, string> row) => new() { Id = id, Current = new(row), Original = new(row) }; // snapshot on load static string DetectChanges(Tracked e) { var changed = e.Current.Keys.Where(k => e.Current[k] != e.Original[k]).OrderBy(k => k).ToList(); return changed.Count == 0 ? $"user {e.Id}: no SQL" : $"user {e.Id}: UPDATE SET {string.Join(", ", changed)}"; } public static List<string> Run() { var row = new Dictionary<string, string> { ["Name"] = "Ann", ["Email"] = "ann@a.io", ["City"] = "Oslo" }; var one = Load(1, row); one.Current["Email"] = "ann@b.io"; var two = Load(2, row); two.Current["City"] = "Rome"; two.Current["City"] = "Oslo"; // set back before SaveChanges return new List<string> { DetectChanges(one), DetectChanges(two) }; }}After a successful SaveChanges, added and modified entities become
Unchanged with a fresh snapshot, and deleted ones become Detached.
All the statements run in one transaction by default.
Identity resolution
Within one context there is at most one tracked instance per primary key. Query the same row twice and you get the same object back, and the values already in it win: a tracking query does not overwrite an entity you have modified with what the database now says. The model below reduces the context to an identity map keyed by primary key.
public class User { public int Id; public string Email = ""; } public class Context(Dictionary<int, string> database){ readonly Dictionary<int, User> identityMap = new(); public User Find(int id) { if (identityMap.TryGetValue(id, out var tracked)) return tracked; // tracked wins var user = new User { Id = id, Email = database[id] }; identityMap[id] = user; return user; }} public static class Solution{ public static List<string> Run() { var database = new Dictionary<int, string> { [1] = "ann@a.io" }; var db = new Context(database); var first = db.Find(1); first.Email = "local@edit.io"; // not saved yet database[1] = "someone-else@b.io"; // another request saves a change var second = db.Find(1); return new List<string> { "same instance: " + ReferenceEquals(first, second), "email: " + second.Email, }; }}Choosing an API
AsNoTracking()for read-only queries: no snapshots, less memory, faster materialisation.db.Users.Update(user)on a detached object marks every property modified, so theUPDATEwrites every column, including ones another request changed in the meantime.Removeon an entity that is stillAddedsimply detaches it; no SQL is sent.ExecuteUpdateAsync/ExecuteDeleteAsync(EF Core 7 and later) run one set-based statement directly and bypass the change tracker entirely, so tracked instances in the context are not updated.
Common mistakes
- Calling
Update(entity)on an entity that is already tracked, "to be safe". It turns a one-columnUPDATEinto an every-column one. - Keeping one
DbContextalive for a long batch job. The tracker grows with every entity read, andDetectChangesgets slower each time. - Expecting a second query to refresh a tracked entity. It returns the
tracked instance; use
Reload()or a new context. - Tracking large read-only result sets. Use
AsNoTracking().
What interviewers ask
Exercise · Pro
What will SaveChanges send?
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade