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
AsNoTrackingand when to use it- The identity map
- Attaching and detaching
- The disconnected update problem
- Updating a graph
- Auditing with a
SaveChangesoverride
Entity states
| State | Meaning | SaveChanges does |
|---|---|---|
Detached | Not tracked | Nothing |
Unchanged | Tracked, no changes | Nothing |
Added | New | INSERT |
Modified | Tracked, changed | UPDATE |
Deleted | Marked for removal | DELETE |
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
PublicIdcannot 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
| Message | Cause | Fix |
|---|---|---|
| An unintended update is written | A tracked entity was modified and SaveChanges picked it up | AsNoTracking for reads |
The instance of entity type cannot be tracked | Duplicate key in the tracker | Use one instance |
| Memory grows on a long-running context | Everything read is tracked | Short-lived contexts; AsNoTracking |
| Changes to a detached object are ignored | Not tracked | Attach and set the state |
SaveChanges writes columns you did not touch | Whole entity marked Modified | Set 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
AsNoTrackingon a load, then expecting changes to save- No
AsNoTrackingon read-only queries, so the tracker grows Updateon a partially populated detached entity, wiping unset columns- Trusting a request's
Idwithout a tenant check - Expecting a second query to return fresh values for a tracked entity
- A long-lived context accumulating tracked entities
ExecuteUpdatebypassing aSaveChangesaudit override- Writing
0rather thannullfor absent marks - Guessing at "why did that not save" instead of reading
DebugView
Practice
The course exercise is updating disconnected data basics.
- Load a student, change one property, print
_context.Entry(student).Statebefore and after. - Print
ChangeTracker.DebugView.LongViewbeforeSaveChangesand read the original and current values. - Load with
AsNoTracking, modify, save. Confirm nothing is written and nothing throws. - Load the same student twice in one context. Confirm
ReferenceEqualsis true and that an unsaved change survives the second query. - Call
ReloadAsyncand confirm the database values return. - Write the wrong
PUTendpoint withUpdateon a partial entity. Save it and inspect the row — record which columns were destroyed. - Rewrite it as load-then-modify. Confirm only the sent properties changed.
- Write it a third way with
ExecuteUpdateAsyncand confirm the tenant filter and the row count check both work. - 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. - Add the
IAuditableSaveChangesoverride. ConfirmCreatedAtis stamped on insert and not overwritten on update. - Run an
ExecuteUpdateAsyncon an auditable entity and confirm the stamp did not apply. - Convert
Removeto 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
SaveChangeswill write - Choose tracking or
AsNoTrackingdeliberately - Inspect the change tracker when a save surprises you
- Reattach a detached entity correctly
- Say why a
DbContextshould live for one request
Review questions
- Why does modifying an
AsNoTrackingentity save nothing? - What is the identity map, and how can it return stale data?
- Why does
Updateon a partially populated detached entity destroy data? - Why does
ExecuteUpdateskip aSaveChangesaudit override?