Mert Özen Further With Every Line
Database Entity Framework Core — #11

Change Tracking in EF Core: Entity States, AsNoTracking and Identity Resolution

Mert Özen Nov 5, 2026 10 min 4 views
Change Tracking in EF Core: Entity States, AsNoTracking and Identity Resolution

We open up EF Core's change tracker: the five entity states, ChangeTracker.Entries and DebugView, the one-object-per-row rule (identity resolution), AsNoTracking vs AsNoTrackingWithIdentityResolution, when DetectChanges runs, and the 'already being tracked' error.

Version note: The code in this post is written for EF Core 6 and runs unchanged on EF Core 10.

Guess First: Which Columns Get Updated?

Book number 1 in the database already has the title "A Wizard of Earthsea" and a price of 12.50. When this code runs, which columns appear in the UPDATE command?

var book = db.Books.Single(b => b.Id == 1);
book.Title = "A Wizard of Earthsea";
book.Price = 13.00m;
db.SaveChanges();

We assigned values to both properties. But the command EF Core sends contains only one of them:

UPDATE "Books" SET "Price" = @p0
WHERE "Id" = @p1;
SELECT changes();

Title was assigned, but its value did not change, and EF Core knows it. How? The moment the book came back from the query, EF Core kept a copy of every property value. When SaveChanges was called, it compared each property's current value with that copy and wrote only the ones that differ. This mechanism is called change tracking, and the part doing the work is the change tracker. In this post we look inside it: entity states, how to read the change tracker, identity resolution, AsNoTracking and DetectChanges.

EF Core 10 ends the same UPDATE with RETURNING 1 instead of SELECT changes(); SQLite has used RETURNING since EF Core 7. The behavior is the same.

The Model

A short model is enough for this post. We seed the data with HasData and log only the executed SQL commands:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

namespace Bookstore;

public class Author
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public List<Book> Books { get; set; } = new();
}

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = null!;
    public decimal Price { get; set; }
    public int AuthorId { get; set; }
    public Author Author { get; set; } = null!;
}

public class BookstoreContext : DbContext
{
    public DbSet<Author> Authors => Set<Author>();
    public DbSet<Book> Books => Set<Book>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options
            .UseSqlite("Data Source=bookstore.db")
            .LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted });

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Author>().HasData(
            new Author { Id = 1, Name = "Ursula K. Le Guin" },
            new Author { Id = 2, Name = "Isaac Asimov" });

        modelBuilder.Entity<Book>().HasData(
            new Book { Id = 1, Title = "A Wizard of Earthsea", Price = 12.50m, AuthorId = 1 },
            new Book { Id = 2, Title = "The Left Hand of Darkness", Price = 14.00m, AuthorId = 1 },
            new Book { Id = 3, Title = "Foundation", Price = 11.00m, AuthorId = 2 });
    }
}

Each example starts with a clean database and a new BookstoreContext.

Entity States: The Life Cycle of an Object

The change tracker keeps a state for every entity it tracks. There are five, and we can see all of them over the life of one author:

var author = new Author { Name = "Stanislaw Lem" };
Console.WriteLine($"1. {db.Entry(author).State}");

db.Authors.Add(author);
Console.WriteLine($"2. {db.Entry(author).State}");

db.SaveChanges();
Console.WriteLine($"3. {db.Entry(author).State}, Id = {author.Id}");

author.Name = "Stanislaw Herman Lem";
Console.WriteLine($"4. {db.Entry(author).State}");
db.SaveChanges();

db.Authors.Remove(author);
Console.WriteLine($"5. {db.Entry(author).State}");

db.SaveChanges();
Console.WriteLine($"6. {db.Entry(author).State}");
1. Detached
2. Added
3. Unchanged, Id = 3
4. Modified
5. Deleted
6. Detached

Detached: the context does not know this object. Every object you create with new starts here. Added: it will be INSERTed on SaveChanges. After saving, the state went back to Unchanged and Id was filled with the value the database generated. Modified: at least one property changed; it will be UPDATEd. Deleted: Remove was called; it will be DELETEd, and after saving the object becomes Detached again.

Every SaveChanges is really these states translated into SQL: Added → INSERT, Modified → UPDATE, Deleted → DELETE, Unchanged → nothing. The guessing question at the start of the post follows from this: the book's state is Modified, but only the property marked as modified went into the UPDATE.

Looking Inside the Change Tracker

You can see everything that is tracked with ChangeTracker.Entries():

var books = db.Books.Where(b => b.AuthorId == 1).ToList();
books[0].Price = 9.99m;
db.Books.Remove(books[1]);
db.Authors.Add(new Author { Name = "Stanislaw Lem" });

foreach (var entry in db.ChangeTracker.Entries())
{
    Console.WriteLine($"{entry.Metadata.ClrType.Name,-7} {entry.State}");
}

var priceEntry = db.Entry(books[0]).Property(b => b.Price);
Console.WriteLine($"Price: {priceEntry.OriginalValue} -> {priceEntry.CurrentValue}, modified: {priceEntry.IsModified}");
Author  Added
Book    Modified
Book    Deleted
Price: 12.5 -> 9.99, modified: True

Entry(...) gives you the record of a single entity. Through Property(...) you reach the original value, the current value and the IsModified flag. You can change that flag by hand as well; in the next post we will use it in disconnected scenarios.

There is a faster way while debugging: DebugView, introduced in EF Core 5. In the same situation, db.ChangeTracker.DebugView.ShortView prints:

Author {Id: -2147482646} Added
Book {Id: 1} Modified FK {AuthorId: 1}
Book {Id: 2} Deleted FK {AuthorId: 1}

The new author's Id looks odd: -2147482646. This is a temporary value. The database will generate the real value during the INSERT; until then, EF Core gives the object a temporary, negative key so it can tell objects apart. The more detailed one is LongView. Change the price of a single book and look:

Book {Id: 1} Modified
  Id: 1 PK
  AuthorId: 1 FK
  Price: 9.99 Modified Originally 12.5
  Title: 'A Wizard of Earthsea'
  Author: <null>

Which property changed, its old value and the navigations that were not loaded are all visible at a glance. When a SaveChanges produces SQL you did not expect, this is the first place to look.

Identity Resolution: Same Row, Same Object

In the earlier posts we saw Find return an object without going to the database, and fix-up happen during Include. Behind both is the same rule: within a context, there is only one object for a row with a given key.

var first = db.Books.Single(b => b.Id == 3);
first.Title = "Foundation (unsaved)";

var second = db.Books.Single(b => b.Title == "Foundation");
Console.WriteLine($"Same instance: {ReferenceEquals(first, second)}");
Console.WriteLine($"Title: {second.Title}");
Same instance: True
Title: Foundation (unsaved)

The second query really did go to the database and found the row with WHERE "b"."Title" = 'Foundation'. But looking at the key of the incoming row, EF Core saw it was already tracking book 3 and returned the object it had instead of creating a new one. It did not overwrite the object with the values from the row either; the title is still the unsaved "Foundation (unsaved)". So the query looked for the book titled "Foundation" and handed you an object with a different title.

Confusing as it looks, this is the correct behavior. Otherwise there would be two different objects for the same book, a change made to one would not show in the other, and SaveChanges would not know which one to write. The price is this: a long-lived context shows the values it first loaded, not the current values in the database.

AsNoTracking: When You Only Read

A list screen or a report only reads data. In that case, keeping copies of values, holding states and doing identity resolution is wasted work. AsNoTracking() turns all of it off:

var a = db.Books.AsNoTracking().Single(b => b.Id == 3);
var b2 = db.Books.AsNoTracking().Single(b => b.Id == 3);
Console.WriteLine($"Same instance: {ReferenceEquals(a, b2)}, tracked: {db.ChangeTracker.Entries().Count()}");

a.Price = 1m;
Console.WriteLine($"SaveChanges: {db.SaveChanges()}");
Same instance: False, tracked: 0
SaveChanges: 0

Two separate objects came back for the same row, the change tracker stayed empty and the price change was not saved; SaveChanges returned 0. As far as the context is concerned, these objects do not exist. In a read-heavy app you can also flip the default: write options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking) and every query becomes untracked, then say AsTracking() where you want tracking.

AsNoTracking has a side effect. Without identity resolution, the same related object is created again for every row:

var plain = db.Books.AsNoTracking().Include(b => b.Author).Where(b => b.AuthorId == 1).ToList();
Console.WriteLine($"AsNoTracking: {ReferenceEquals(plain[0].Author, plain[1].Author)}");

var resolved = db.Books.AsNoTrackingWithIdentityResolution().Include(b => b.Author).Where(b => b.AuthorId == 1).ToList();
Console.WriteLine($"WithIdentityResolution: {ReferenceEquals(resolved[0].Author, resolved[1].Author)}, tracked: {db.ChangeTracker.Entries().Count()}");
AsNoTracking: False
WithIdentityResolution: True, tracked: 0

Both books belong to Ursula K. Le Guin, but with AsNoTracking two separate Author objects came back. In a list of a thousand books that means a thousand author objects. AsNoTrackingWithIdentityResolution(), introduced in EF Core 5, keeps an identity map for the duration of the query, gathers the same author into a single object, and lets go of everything when the query is done. The change tracker is still empty. Use it when there are repeating relationships in the graph, or when you do not want the same object repeated while turning the result into JSON.

DetectChanges: Who Notices the Change?

The line book.Price = 9.99m is an ordinary property assignment. It tells EF Core nothing. So how does the state become Modified? EF Core itself calls DetectChanges(), which compares the current values of every tracked entity with the saved copy, at certain moments: before SaveChanges, in Entries() and Entry() calls, in HasChanges() and in a few other places. Turning off the automatic call makes this visible:

db.ChangeTracker.AutoDetectChangesEnabled = false;

var book = db.Books.Single(b => b.Id == 1);
book.Price = 9.99m;
Console.WriteLine($"Before DetectChanges: {db.Entry(book).State}");
Console.WriteLine($"HasChanges: {db.ChangeTracker.HasChanges()}");

db.ChangeTracker.DetectChanges();
Console.WriteLine($"After DetectChanges: {db.Entry(book).State}");
Before DetectChanges: Unchanged
HasChanges: False
After DetectChanges: Modified

The price changed, but until EF Core did the comparison the book looked Unchanged. Why would you want to turn it off? DetectChanges walks every tracked entity. If you track thousands of entities and call something that triggers the scan, such as Entries() or HasChanges(), inside a loop, every iteration walks the whole list again and the work slows down quadratically. In EF Core the Add call itself does not trigger the scan (classic Entity Framework 6 did), so you will not see a difference in a plain insert loop. In batch work, turning off automatic detection and calling DetectChanges() once at the end is a well-known optimization.

Common Pitfalls

Turning off automatic detection and forgetting to turn it back on. While AutoDetectChangesEnabled = false, SaveChanges does not call DetectChanges either. In the same example as above, when we called SaveChanges() directly instead of DetectChanges, the result was 0; no UPDATE was sent and no error was raised. If you turn it off, call it by hand before saving, or turn it back on with a try/finally.

Bringing a second object with the same key into tracking. If the context is already tracking a book and you try to hand it a new object with the same Id through Update or Attach:

var tracked = db.Books.Single(b => b.Id == 1);
var incoming = new Book { Id = 1, Title = "A Wizard of Earthsea", Price = 15.00m, AuthorId = 1 };
db.Books.Update(incoming);
System.InvalidOperationException: The instance of entity type 'Book' cannot be tracked because another instance with the same key value for {'Id'} is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the conflicting key values.

The identity resolution rule hits you hard here. This error usually shows up in an API when you first read the record to check "does it exist" and then try to Update the object that came from the client. We will talk about the fixes in the next post.

A long-lived context. The more queries a context runs, the more objects it tracks; memory grows, DetectChanges slows down and identity resolution shows you stale data. Use a context for a short unit of work: a web request, a command, a screen. If you are forced to have a long-lived context, db.ChangeTracker.Clear() (since EF Core 5) lets go of everything tracked; in the test, the count of 3 became 0 after Clear().

Changing an untracked object and assuming it was saved. A change made to an object that came from AsNoTracking goes nowhere unless you explicitly attach the object to the context. How to do that is the topic of the next post as well.

A Small Experiment

★☆☆ Load a book, change its Title, then set it back to the old value. Look at DebugView.LongView before SaveChanges and note the book's state. Then call SaveChanges and check in the logs how many UPDATEs were sent.

★★☆ Run the same query three ways: with the default tracking, with AsNoTracking() and with AsNoTrackingWithIdentityResolution(). After each, print ChangeTracker.Entries().Count() and the ReferenceEquals result for the author objects, and build the table yourself.

★★★ Write a program that adds 20,000 books in a loop and reports progress every 100 books with db.ChangeTracker.Entries<Book>().Count(), and measure it with Stopwatch. Then set AutoDetectChangesEnabled = false before the loop, call DetectChanges() once after it, and measure again. Remove the reporting line and repeat both measurements; explain where the difference comes from. In every case, verify that SaveChanges returns 20,000.

We can now see what the change tracker knows and when. The next question is how that knowledge is written to the database: how does SaveChanges group commands, how do you save a related graph in one go, and how do you update an object that came from an API and that the context has never seen? In the next post we talk about saving data.