Change Tracking
By the end of this lesson
Explain what tracking does and when to disable it.
When EF Core hands you an entity from a query, it keeps a record of it: the object itself, plus a snapshot of the values it had when it arrived. That record is a tracking entry, and the collection of them is the change tracker.
This is how SaveChanges works. You never tell it that a salary changed. It compares the current property values with the snapshot, finds the one difference, and writes an UPDATE for that column.
Everything in this lesson follows from those two sentences: the cost of tracking, the reason for AsNoTracking, and the behaviour that surprises people most.
Employee employee = await context.Employees.FirstAsync(e => e.Id == 14);
Console.WriteLine(context.Entry(employee).State); // Unchanged
employee.AnnualSalary = 61_500m;
Console.WriteLine(context.Entry(employee).State); // Modified
Console.WriteLine(context.Entry(employee)
.Property(e => e.AnnualSalary).OriginalValue); // 58000 — the snapshot
await context.SaveChangesAsync();
Console.WriteLine(context.Entry(employee).State); // Unchanged again- Nothing between the query and SaveChanges mentions the database. The change is an ordinary property assignment on an ordinary object.
- The state moved from Unchanged to Modified because EF Core detected the difference. Detection runs when you ask for state, before SaveChanges, and before most queries.
- OriginalValue is the snapshot. Hold on to this for the concurrency lesson: EF Core knows both what the value was when it read the row and what you want it to become.
- After a successful save the entry returns to Unchanged and the snapshot is reset. The context now believes its objects match the database.
UPDATE [Employees] SET [AnnualSalary] = @p0
OUTPUT 1
WHERE [Id] = @p1;
-- @p0 = 61500.00
-- @p1 = 14- One column. EF Core wrote only what changed, because the snapshot told it the other columns were untouched. This is worth knowing precisely, because it means two people editing different fields of the same row do not always collide.
- OUTPUT 1 is how EF Core learns whether a row was actually updated. If nothing comes back, the row is gone or no longer matches the WHERE clause. That is the mechanism behind concurrency detection two lessons from now.
- The exact text varies by EF Core version and provider — earlier versions used SELECT @@ROWCOUNT for the same purpose. Read your own log rather than trusting this sample.
The change tracker is also a lookup keyed on the primary key. Ask the same context for employee 14 twice and you get the same object, not two objects holding equal values. That lookup is called the identity map.
It has to work this way. If two objects in one context represented the same row and you changed both, SaveChanges would have no way to decide which one is right.
Employee first = await context.Employees.FirstAsync(e => e.Id == 14);
Employee second = await context.Employees.SingleAsync(e => e.Id == 14);
Console.WriteLine(ReferenceEquals(first, second)); // True
first.Role = "Senior Engineer";
Console.WriteLine(second.Role); // Senior Engineer
// No tracking, no identity map: two results, two objects.
Employee a = await context.Employees.AsNoTracking().FirstAsync(e => e.Id == 14);
Employee b = await context.Employees.AsNoTracking().FirstAsync(e => e.Id == 14);
Console.WriteLine(ReferenceEquals(a, b)); // False- Both tracked queries ran against the database. The second one still handed back the object EF Core already had, because the key matched an existing entry.
- So a change made through one reference is visible through the other. There is one object, with two names for it.
- AsNoTracking removes the entry, and with it the identity map. The two results are separate objects that happen to hold the same values, and neither of them can be saved.
- There is a consequence people hit in long-running code: because the tracked object wins, a tracked query will not necessarily show you a change another process committed after you first loaded the row. Use ReloadAsync, a no-tracking query, or a fresh context when you need current values.
The decision is almost always read-only versus read-and-write:
| Tracked query (the default) | AsNoTracking query | |
|---|---|---|
| What the context keeps | The object, a snapshot of every loaded property, and an identity map entry | Nothing, once the objects are handed to you |
| Can you change it and save? | Yes — this is the mechanism | Not directly; the context is not watching the object |
| The same row queried twice | One object, shared | Two separate objects with equal values |
| Cost | Memory per entity, plus change detection work | Lower memory, less work, measurably faster on large result sets |
| Fits | Load, change, save | Lists, reports, API responses, anything read-only |
Summary
- Tracking keeps the object plus a snapshot of its loaded values in the change tracker
- SaveChanges compares object with snapshot, so only changed columns appear in the UPDATE
- The identity map means the same row in one context is the same object instance
- AsNoTracking removes both, which is faster and lighter for read-only queries and cannot be saved
- Keep contexts short-lived: the tracker grows with everything the context has loaded
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Prove the identity map to yourself
Query the same employee twice in one context and compare the results with ReferenceEquals.
Change a property through one reference and read it through the other.
Then repeat the whole thing with AsNoTracking on both queries and explain the difference in the output.
Show solution
Tracked: ReferenceEquals is true and the change is visible through both references, because there is a single object in the identity map.
Untracked: ReferenceEquals is false and the change is visible through only one, because they are two unrelated objects that were built from two result sets.
The reason to run this rather than read it is that the tracked behaviour looks like caching and is not. Both queries went to the database; only the object construction was skipped.
using AppDbContext context = factory.CreateDbContext();
Employee x = await context.Employees.FirstAsync(e => e.Id == 14);
Employee y = await context.Employees.FirstAsync(e => e.Id == 14);
Console.WriteLine(ReferenceEquals(x, y));
Console.WriteLine(context.ChangeTracker.Entries<Employee>().Count());Think about it
Should no-tracking be the default?
A colleague proposes setting the context default to no-tracking for the whole application, so nobody has to remember AsNoTracking.
What do you gain, what breaks, and how would the breakage present itself?
Show solution
You gain the read-path saving everywhere without anyone remembering it. EF Core supports this through the context options, so it is a real option rather than a hypothetical one.
What breaks is every write path that loads an entity, changes it and saves. Those paths stop working, and the failure is silent: SaveChanges finds nothing to do and reports success. No exception, no log entry, no update.
That silence is the argument against it. A default that makes correct-looking code do nothing is an expensive trade for a saving you could get by adding AsNoTracking on the read paths deliberately. If you do flip the default, write a test that proves a save still works, because nothing else will tell you.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.