Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close

    Guided course · Data Access in .NET

    EF Core Performance and Behavior: back to the course

    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. DetectChanges runs automatically before SaveChanges, and also on ChangeTracker.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 UPDATE sets 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 entity Added.
      • 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.
    • ChangeTracker.Clear() detaches everything. Use it in long-running loops, or after a failure, to stop the tracker growing.
    • Debugging. ChangeTracker.DebugView.ShortView / LongView list tracked entities, their states and modified properties.

    Code example: states, partial updates and Update vs Attach

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

    Text
    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} Deleted

    Code walkthrough

    • The state moved Detached → Added → Unchanged as the entity was added and saved. After the save, the generated Id = 1 was written back into the object.
    • After editing Name, Entries() ran DetectChanges and reported Modified. The property API shows original and current values.
    • The UPDATE sets only "Name", because only Name differed from the snapshot.
    • The second query returned the same instance (identity map).
    • Update(detached) marked both Name and Rating modified, so it would overwrite both columns. Attach(stub) plus setting Rating produced an UPDATE of "Rating" only.
    • Remove marked the entity Deleted. 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 DetectChanges then 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

    1. You load a Customer, change nothing and call SaveChanges. What SQL runs?

    Answer: None. DetectChanges finds no differences, so there are no commands.

    1. A PUT endpoint calls db.Update(dtoMappedToEntity) where the DTO has no CreatedAt. 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.

    1. Why can a tight loop of Add + SaveChanges over 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.

    1. 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 + DetectChanges decides 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; DebugView for debugging.

    Analogy

    Everyday picture

    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 before SaveChanges.
    • 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.
    Sign in to mark lessons done and keep your place in the course.Sign in