Skip to main content
ANVISoftware Solutions
Lesson 11 of 17Intermediate15 min

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.

Watching an entry change state
C#
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.
The UPDATE that came out of it
SQL
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.

The same row is the same object
C#
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 keepsThe object, a snapshot of every loaded property, and an identity map entryNothing, once the objects are handed to you
Can you change it and save?Yes — this is the mechanismNot directly; the context is not watching the object
The same row queried twiceOne object, sharedTwo separate objects with equal values
CostMemory per entity, plus change detection workLower memory, less work, measurably faster on large result sets
FitsLoad, change, saveLists, 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.

C#
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.

You load an Employee with AsNoTracking, set AnnualSalary to a new value, and call SaveChangesAsync. What happens?
How does SaveChanges know which columns to put in the UPDATE?

Saved in this browser only.