Module 1 · Foundations · Lesson 2 of 18
Change Tracking: Entity States, Snapshots, DetectChanges and the Identity Map
Learning outcome
You can name the five entity states, explain how EF Core finds changes with a snapshot, predict which columns an UPDATE will contain, and choose between Update() and Attach() plus setting single properties for a disconnected entity. You can read ChangeTracker.DebugView when debugging.
Intuition
A tracking DbContext keeps a ledger. For every entity it loaded or was given, it stores the entity, its state and a snapshot of the original values. SaveChanges walks the ledger, compares current values with the snapshot (DetectChanges) and turns the differences into INSERT, UPDATE and DELETE statements.
stateDiagram-v2
[*] --> Detached
Detached --> Added: Add()
Detached --> Unchanged: query / Attach()
Unchanged --> Modified: property changed + DetectChanges
Unchanged --> Deleted: Remove()
Added --> Unchanged: SaveChanges (INSERT)
Modified --> Unchanged: SaveChanges (UPDATE)
Deleted --> Detached: SaveChanges (DELETE)Deep dive
- States:
Detached(not tracked),Added,Unchanged,Modified,Deleted. - Snapshot change tracking is the default. EF keeps a copy of original values.
DetectChangesruns automatically beforeSaveChanges, and also onChangeTracker.Entries(),Entry()and some other APIs. Its cost is O(tracked entities × properties). - Partial updates. Only properties marked modified are written. Change one column and the
UPDATEsets one column. - Identity map. Inside one context, a given key maps to one instance. A second query for the same row returns the same object (and does not overwrite your unsaved edits with database values).
- Disconnected entities (APIs). The context that loaded an entity is gone by the time the client sends it back.
- For an existing-key entity,
Update(entity)marks updatable properties modified, including omitted DTO fields; keys and store-generated properties are not all written. An unset generated key can instead mark the entityAdded. Attach(stub)and then setting the properties you mean to change writes only those columns.- Loading the row and copying the incoming values onto it (
Entry(e).CurrentValues.SetValues(dto)) gives correct partial updates at the cost of one query.
- For an existing-key entity,
ChangeTracker.Clear()detaches everything. Use it in long-running loops, or after a failure, to stop the tracker growing.- Debugging.
ChangeTracker.DebugView.ShortView/LongViewlist tracked entities, their states and modified properties.
Code example: states, partial updates and Update vs Attach
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using var db = new BlogDb();
db.Database.OpenConnection();
db.Database.EnsureCreated();
var blog = new Blog { Name = "EF notes" };
Console.WriteLine($"new object: {db.Entry(blog).State}");
db.Blogs.Add(blog);
Console.WriteLine($"after Add: {db.Entry(blog).State}");
db.SaveChanges();
Console.WriteLine($"after SaveChanges: {db.Entry(blog).State}, Id = {blog.Id}");
// Snapshot change tracking: EF compares current values with the snapshot taken at load time.
blog.Name = "EF Core notes";
Console.WriteLine($"after edit (Entries() runs DetectChanges): {db.ChangeTracker.Entries().Single().State}"); // Entries() calls DetectChanges
var nameProp = db.Entry(blog).Property(b => b.Name);
Console.WriteLine($"Name modified? {nameProp.IsModified}; original '{nameProp.OriginalValue}' -> current '{nameProp.CurrentValue}'");
BlogDb.Log = true;
db.SaveChanges(); // UPDATE only the changed column
BlogDb.Log = false;
// The identity map: the same key returns the same instance inside one context.
var again = db.Blogs.Single(b => b.Id == blog.Id);
Console.WriteLine($"same instance from second query? {ReferenceEquals(blog, again)}");
// Disconnected update: for an existing-key entity, Update() marks updatable properties modified; Attach + set one property updates one column.
using var db2 = new BlogDb(db.Database.GetDbConnection());
var detached = new Blog { Id = blog.Id, Name = "Renamed", Rating = 5 };
db2.Update(detached);
Console.WriteLine($"Update(): state {db2.Entry(detached).State}, modified = [{string.Join(", ", db2.Entry(detached).Properties.Where(p => p.IsModified).Select(p => p.Metadata.Name))}]");
db2.ChangeTracker.Clear();
var stub = new Blog { Id = blog.Id };
db2.Attach(stub);
stub.Rating = 4;
Console.WriteLine($"Attach + set Rating: modified = [{string.Join(", ", db2.Entry(stub).Properties.Where(p => p.IsModified).Select(p => p.Metadata.Name))}]");
BlogDb.Log = true;
db2.SaveChanges();
BlogDb.Log = false;
// Remove marks Deleted; SaveChanges issues DELETE.
db2.Remove(stub);
Console.WriteLine($"after Remove: {db2.Entry(stub).State}");
Console.WriteLine("-- ChangeTracker.DebugView.ShortView:");
Console.Write(db2.ChangeTracker.DebugView.ShortView);
public class Blog { public int Id { get; set; } public string Name { get; set; } = ""; public int Rating { get; set; } }
public class BlogDb : DbContext
{
public static bool Log;
private readonly System.Data.Common.DbConnection? _conn;
public BlogDb(System.Data.Common.DbConnection? conn = null) => _conn = conn;
public DbSet<Blog> Blogs => Set<Blog>();
protected override void OnConfiguring(DbContextOptionsBuilder o)
{
if (_conn is null) o.UseSqlite("Data Source=:memory:"); else o.UseSqlite(_conn);
o.LogTo(m => { if (Log) Console.WriteLine(m); }, new[] { RelationalEventId.CommandExecuted }, Microsoft.Extensions.Logging.LogLevel.Information, DbContextLoggerOptions.None)
.EnableSensitiveDataLogging();
}
}Output (EF Core 10.0.12, SQLite)
new object: Detached
after Add: Added
after SaveChanges: Unchanged, Id = 1
after edit (Entries() runs DetectChanges): Modified
Name modified? True; original 'EF notes' -> current 'EF Core notes'
Executed DbCommand (0ms) [Parameters=[@p1='1', @p0='EF Core notes' (Nullable = false) (Size = 13)], CommandType='Text', CommandTimeout='30']
UPDATE "Blogs" SET "Name" = @p0
WHERE "Id" = @p1
RETURNING 1;
same instance from second query? True
Update(): state Modified, modified = [Name, Rating]
Attach + set Rating: modified = [Rating]
Executed DbCommand (0ms) [Parameters=[@p1='1', @p0='4'], CommandType='Text', CommandTimeout='30']
UPDATE "Blogs" SET "Rating" = @p0
WHERE "Id" = @p1
RETURNING 1;
after Remove: Deleted
-- ChangeTracker.DebugView.ShortView:
Blog {Id: 1} DeletedCode walkthrough
- The state moved
Detached → Added → Unchangedas the entity was added and saved. After the save, the generatedId = 1was written back into the object. - After editing
Name,Entries()ranDetectChangesand reportedModified. The property API shows original and current values. - The
UPDATEsets only"Name", because onlyNamediffered from the snapshot. - The second query returned the same instance (identity map).
Update(detached)marked bothNameandRatingmodified, so it would overwrite both columns.Attach(stub)plus settingRatingproduced anUPDATEof"Rating"only.Removemarked the entityDeleted. The DebugView line shows it.
Failure modes
Update()on a partially filled DTO-mapped entity, which overwrites columns with defaults (nulls, zeros).- Long-lived contexts (a singleton, or one per batch job) that grow the tracker without bound. Each
DetectChangesthen gets slower. - Expecting a second query to show fresh database values for an entity that is already tracked. It returns the tracked instance.
- Attaching two instances with the same key:
InvalidOperationException("another instance with the same key value is already being tracked").
Interview angle
"What does SaveChanges actually do?" Answer: it runs DetectChanges (snapshot comparison), orders the commands by dependency, batches them, wraps them in a transaction, executes them, then propagates generated values (identity, rowversion); saved added/modified entities become Unchanged, while deleted entities become Detached. Follow with the disconnected-update trap: For an existing-key entity, Update() marks updatable properties modified, including omitted DTO fields.
Practice questions
- You load a
Customer, change nothing and callSaveChanges. What SQL runs?
Answer: None. DetectChanges finds no differences, so there are no commands.
- A PUT endpoint calls
db.Update(dtoMappedToEntity)where the DTO has noCreatedAt. What happens?
Answer: CreatedAt is marked modified with its default value and overwritten in the database. Load and apply values, or attach and mark only the sent properties.
- Why can a tight loop of
Add+SaveChangesover 100k entities slow down over time?
Answer: The tracker keeps all 100k entities, and each SaveChanges runs DetectChanges over all of them: O(n²) overall. Save in batches with ChangeTracker.Clear(), or use bulk APIs.
- What is the identity map, and when is it a problem?
Answer: One instance per key per context. It prevents duplicates, but it means queries do not refresh tracked entities, and it costs memory for read-only workloads (Lesson 3).
Revision checklist
- Five states; snapshot +
DetectChangesdecides modified properties. - UPDATE contains only modified columns.
- Existing-key entity:
Update()marks updatable properties modified;Attach+ set marks only those changed. - Identity map: same key → same instance.
ChangeTracker.Clear()for long loops;DebugViewfor debugging.
Related on TechInterviewNotes
- Course lesson: DbContext identity and safe disconnected updates (EF Core Data Correctness course)
- Free cheat sheet: EF Core Loading Cheat Sheet (tracking vs no-tracking table)
Analogy
The hotel front desk
The context is a front desk with a guest register. Each guest (entity) has a row: checked in (Unchanged), arriving (Added), room changed (Modified), checking out (Deleted). At the end of the shift (SaveChanges) the clerk compares the register with the morning photocopy (the snapshot) and files only the differences. Update() is writing "everything changed" on a guest's row because you did not check what did.
Quick reference
Change tracker
- States: Detached, Added, Unchanged, Modified, Deleted.
DetectChanges: snapshot compare, O(tracked × properties); automatic beforeSaveChanges.Entry(e).Property(p).IsModified / OriginalValue / CurrentValue.- Existing-key entity:
Update(e)marks updatable properties modified.Attach(e)+ set: only those changed. ChangeTracker.Clear(),ChangeTracker.DebugView.LongView.