Skip to main content
Published / updated

Change Tracking and Disconnected Updates

Before you start

You need: relationships (Article 06).

Time: about 50 minutes, plus the practice. This explains the surprises from Articles 05 and 06.

Learning objective

Predict what SaveChanges will write, and update an entity that arrives detached from a web request without losing data.

Topics

  • Entity states
  • How change detection works
  • AsNoTracking and when to use it
  • The identity map
  • Attaching and detaching
  • The disconnected update problem
  • Updating a graph
  • Auditing with a SaveChanges override

Entity states

StateMeaningSaveChanges does
DetachedNot trackedNothing
UnchangedTracked, no changesNothing
AddedNewINSERT
ModifiedTracked, changedUPDATE
DeletedMarked for removalDELETE
Student student = await _context.Students.FirstAsync(s => s.Id == 12, ct);
Console.WriteLine(_context.Entry(student).State); // Unchanged

student.ClassName = "11th";
Console.WriteLine(_context.Entry(student).State); // Modified

_context.Students.Remove(student);
Console.WriteLine(_context.Entry(student).State); // Deleted

Inspecting state and the tracker is the fastest way to answer "why did that not save?":

foreach (EntityEntry entry in _context.ChangeTracker.Entries())
{
Console.WriteLine($"{entry.Entity.GetType().Name}: {entry.State}");
}

Console.WriteLine(_context.ChangeTracker.DebugView.LongView);

DebugView.LongView prints every tracked entity, its state, and each property's original and current value. Use it before guessing.

How change detection works

EF Core keeps a snapshot of every tracked entity's property values as loaded. SaveChanges calls DetectChanges, which compares current values to the snapshot and marks the differences.

That is why modifying a loaded entity needs no Update call — and why the UPDATE sets only the columns that actually changed.

EntityEntry<Student> entry = _context.Entry(student);

Console.WriteLine(entry.Property(s => s.ClassName).OriginalValue); // "10th"
Console.WriteLine(entry.Property(s => s.ClassName).CurrentValue); // "11th"
Console.WriteLine(entry.Property(s => s.ClassName).IsModified); // True

Detection is O(tracked entities × properties). With 50 entities it is free; with 50,000 it dominates the operation. That cost is the main argument for AsNoTracking and for short-lived contexts.

AsNoTracking

List<Student> students = await _context.Students
.AsNoTracking()
.Where(s => s.SchoolId == schoolId && s.Status == StudentStatus.Active)
.ToListAsync(ct);

No snapshots, no tracker entries, less memory, faster query.

Use it for every read-only query. A list page, a report, an export — anything you display and discard.

The consequence: changes to the returned entities save nothing.

Student student = await _context.Students.AsNoTracking().FirstAsync(s => s.Id == 12, ct);

student.ClassName = "11th";
await _context.SaveChangesAsync(ct); // writes nothing, throws nothing

Silent no-op. When "the save does nothing" is the report, AsNoTracking on the load is the first thing to check.

A projection is never tracked, so AsNoTracking on a Select into a DTO changes nothing:

// Already untracked — AsNoTracking is redundant
List<StudentDto> dtos = await _context.Students
.Select(s => new StudentDto { Name = s.Name })
.ToListAsync(ct);

Set it as the default when most queries are reads:

options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);

Then opt in with .AsTracking() where you intend to modify. This is a good default for a read-heavy API, and it makes tracking a deliberate choice.

The identity map

A DbContext returns the same instance for the same key within its lifetime.

Student first = await _context.Students.FirstAsync(s => s.Id == 12, ct);
Student second = await _context.Students.FirstAsync(s => s.Id == 12, ct);

Console.WriteLine(ReferenceEquals(first, second)); // True — one query, then the tracker

The second call still queries the database, but the tracked instance wins and the fresh values are discarded. So:

Student student = await _context.Students.FirstAsync(s => s.Id == 12, ct);
student.ClassName = "11th"; // not saved

Student again = await _context.Students.FirstAsync(s => s.Id == 12, ct);
Console.WriteLine(again.ClassName); // "11th" — your in-memory change

To get the current database values:

await _context.Entry(student).ReloadAsync(ct);

AsNoTracking bypasses the identity map, so every query returns a fresh instance — which is another reason it suits reads.

Attaching detached entities

An entity from a web request, a cache, or another context is Detached. EF Core knows nothing about it.

_context.Attach(student); // Unchanged — no update
_context.Update(student); // Modified, ALL properties
_context.Entry(student).State = EntityState.Modified; // same as Update

_context.Entry(student).Property(s => s.ClassName).IsModified = true; // one property

Update marks every property modified, so the UPDATE sets every column — including ones the caller never sent.

The disconnected update problem

This is the defining EF Core problem in a web application.

// Wrong — overwrites columns the request never carried
[HttpPut("{publicId}")]
public async Task<IActionResult> Update(Guid publicId, StudentUpdateRequest request)
{
Student student = new Student
{
PublicId = publicId,
Name = request.Name,
ClassName = request.ClassName,
Section = request.Section
};

_context.Students.Update(student);
await _context.SaveChangesAsync(ct);

return NoContent();
}

Every property not set — RollNumber, DateOfBirth, ParentName, ParentPhone, Address, SchoolId — is written as its default. RollNumber becomes empty, DateOfBirth becomes 0001-01-01, and SchoolId becomes 0, which moves the student out of their school.

No exception. The API returns 204.

The correct pattern: load, then modify

[HttpPut("{publicId}")]
public async Task<IActionResult> Update(
Guid publicId, StudentUpdateRequest request, CancellationToken ct)
{
int schoolId = GetSchoolIdFromClaims();

Student? student = await _context.Students
.FirstOrDefaultAsync(s => s.SchoolId == schoolId && s.PublicId == publicId, ct);

if (student == null)
{
return NotFound();
}

student.Name = request.Name;
student.ClassName = request.ClassName;
student.Section = request.Section;
student.ParentPhone = request.ParentPhone;

await _context.SaveChangesAsync(ct);

return NoContent();
}

One extra query, and in exchange:

  • Only the properties the request carries are changed
  • The tenant filter is enforced on the load — a leaked PublicId cannot reach another school
  • Returning 404 for a missing record is natural
  • Only genuinely changed columns appear in the UPDATE

This is the pattern to use. The extra round trip is not the bottleneck in any application where this matters.

Setting properties selectively

For a large entity where the load is genuinely undesirable:

Student student = new Student { Id = request.Id, PublicId = publicId };

_context.Attach(student);

student.Name = request.Name;
student.ClassName = request.ClassName;

await _context.SaveChangesAsync(ct);

Attach marks it Unchanged, then assigning properties marks only those Modified. The UPDATE touches two columns.

The cost: no tenant check, so the WHERE must carry it — and Attach requires the real primary key, which means either trusting the request's Id or looking it up anyway.

ExecuteUpdateAsync is usually the better answer when you truly want one statement:

int affected = await _context.Students
.Where(s => s.SchoolId == schoolId && s.PublicId == publicId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(s => s.Name, request.Name)
.SetProperty(s => s.ClassName, request.ClassName), ct);

if (affected == 0)
{
return NotFound();
}

One statement, tenant filter included, no entity loaded, and the row count tells you whether it matched.

Updating a graph

public async Task UpdateExamResultsAsync(
int schoolId, int examId, List<ExamResultInput> inputs, CancellationToken ct)
{
List<ExamResult> existing = await _context.ExamResults
.Where(r => r.SchoolId == schoolId && r.ExamId == examId)
.ToListAsync(ct);

Dictionary<int, ExamResult> byStudent = existing.ToDictionary(r => r.StudentId);

foreach (ExamResultInput input in inputs)
{
ExamResult? current;

if (byStudent.TryGetValue(input.StudentId, out current))
{
current.MarksObtained = input.IsAbsent ? null : input.MarksObtained;
current.IsAbsent = input.IsAbsent;
}
else
{
_context.ExamResults.Add(new ExamResult
{
PublicId = Guid.NewGuid(),
SchoolId = schoolId,
ExamId = examId,
StudentId = input.StudentId,
MarksObtained = input.IsAbsent ? null : input.MarksObtained,
IsAbsent = input.IsAbsent
});
}
}

HashSet<int> submitted = inputs.Select(i => i.StudentId).ToHashSet();

foreach (ExamResult orphan in existing.Where(r => !submitted.Contains(r.StudentId)))
{
_context.ExamResults.Remove(orphan);
}

await _context.SaveChangesAsync(ct);
}

Load the existing set, match by key, update matches, add new ones, remove the rest. All of it saves in one SaveChanges — one transaction.

The input.IsAbsent ? null : input.MarksObtained line is the rule that keeps an absent student off a fail list. Writing 0 there is the bug.

Auditing with a SaveChanges override

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
string user = _currentUser.Name;
DateTime now = DateTime.UtcNow;

foreach (EntityEntry<IAuditable> entry in ChangeTracker.Entries<IAuditable>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
entry.Entity.CreatedBy = user;
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.ModifiedAt = now;
entry.Entity.ModifiedBy = user;

entry.Property(e => e.CreatedAt).IsModified = false;
entry.Property(e => e.CreatedBy).IsModified = false;
}
}

return await base.SaveChangesAsync(cancellationToken);
}

Every entity implementing IAuditable is stamped automatically, with no repository remembering to do it.

The two IsModified = false lines matter: a detached Update would otherwise overwrite CreatedAt and CreatedBy with whatever the request carried.

ExecuteUpdate and ExecuteDelete skip this entirely. They do not go through the change tracker, so an audit stamp implemented this way does not apply. That is a real trade against their performance benefit, and it is worth stating in the code.

Converting soft delete to an interception:

foreach (EntityEntry<Student> entry in ChangeTracker.Entries<Student>())
{
if (entry.State == EntityState.Deleted)
{
entry.State = EntityState.Modified;
entry.Entity.Status = StudentStatus.Inactive;
}
}

Remove now soft-deletes. Combined with a global query filter, deletion behaves correctly everywhere with no caller doing anything special.

Errors you will hit

MessageCauseFix
An unintended update is writtenA tracked entity was modified and SaveChanges picked it upAsNoTracking for reads
The instance of entity type cannot be trackedDuplicate key in the trackerUse one instance
Memory grows on a long-running contextEverything read is trackedShort-lived contexts; AsNoTracking
Changes to a detached object are ignoredNot trackedAttach and set the state
SaveChanges writes columns you did not touchWhole entity marked ModifiedSet individual property states

A DbContext should be short-lived. In a web application it lives for one request — a long-lived one accumulates tracked entities and starts writing things you did not intend.

Common mistakes

  • AsNoTracking on a load, then expecting changes to save
  • No AsNoTracking on read-only queries, so the tracker grows
  • Update on a partially populated detached entity, wiping unset columns
  • Trusting a request's Id without a tenant check
  • Expecting a second query to return fresh values for a tracked entity
  • A long-lived context accumulating tracked entities
  • ExecuteUpdate bypassing a SaveChanges audit override
  • Writing 0 rather than null for absent marks
  • Guessing at "why did that not save" instead of reading DebugView

Practice

The course exercise is updating disconnected data basics.

  1. Load a student, change one property, print _context.Entry(student).State before and after.
  2. Print ChangeTracker.DebugView.LongView before SaveChanges and read the original and current values.
  3. Load with AsNoTracking, modify, save. Confirm nothing is written and nothing throws.
  4. Load the same student twice in one context. Confirm ReferenceEquals is true and that an unsaved change survives the second query.
  5. Call ReloadAsync and confirm the database values return.
  6. Write the wrong PUT endpoint with Update on a partial entity. Save it and inspect the row — record which columns were destroyed.
  7. Rewrite it as load-then-modify. Confirm only the sent properties changed.
  8. Write it a third way with ExecuteUpdateAsync and confirm the tenant filter and the row count check both work.
  9. Implement the exam-results graph update. Submit a list with one changed, one new and one missing result, and confirm the update, insert and delete all happen in one SaveChanges.
  10. Add the IAuditable SaveChanges override. Confirm CreatedAt is stamped on insert and not overwritten on update.
  11. Run an ExecuteUpdateAsync on an auditable entity and confirm the stamp did not apply.
  12. Convert Remove to soft delete in the override, add the global query filter, and confirm a removed student disappears from every query.

Exercise 6 is the one to see once. It destroys real data, returns success, and looks entirely reasonable in review.

You can now

  • Predict what SaveChanges will write
  • Choose tracking or AsNoTracking deliberately
  • Inspect the change tracker when a save surprises you
  • Reattach a detached entity correctly
  • Say why a DbContext should live for one request

Review questions

  1. Why does modifying an AsNoTracking entity save nothing?
  2. What is the identity map, and how can it return stale data?
  3. Why does Update on a partially populated detached entity destroy data?
  4. Why does ExecuteUpdate skip a SaveChanges audit override?

Next: Student records API project