Developer insights Dot Net core

Change Tracking in .NET Core (EF Core) — Step-by-Step Guide with Example

Change Tracking in .NET Core EF Core step-by-step example illustration

Change Tracking in .NET Core define how Entity Framework Core (EF Core) detects and remembers what happened to your entity objects so it can generate the correct SQL when you call SaveChanges(). This blog explains the concepts and shows a real, runnable minimal API example that demonstrates adding, modifying, no-tracking queries, and performance tips.

Why Change Tracking in .NET Core matters (short)

  • Saves you from writing manual SQL for updates.
  • Lets EF generate optimized INSERT / UPDATE / DELETE
  • Wrong use (e.g., marking everything Modified) can cause performance or data-loss problems.
  • Understanding changes tracking in EF is useful for concurrency, detached contexts (e.g., Web APIs or crud activities), and bulk operations.

Quick concepts –

  • ChangeTracker — API on DbContext that monitors tracked entities.
  • EntityStateAdded, Unchanged, Modified, Deleted, Detached.
  • DetectChanges() — Compares snapshots or listens to notifications to find property changes.
  • AsNoTracking() — A query option for read-only queries; returned entities are not tracked (better read performance).
  • AutoDetectChangesEnabled feature — When set to false, EF will not detect changes automatically; this feature is mainly useful for large bulk operations (call DetectChanges() manually).
  • Entry(entity).Property(…).IsModified feature — Identify particular attributes as updated (helpful for partial updates).
  • Attach/Update — Attach preserves state as Unchanged (unless you change it); setting Entry.State = Modified marks all properties modified.

The real example — Minimal API + EF Core + SQLite

We’ll create a tiny Web API project that demonstrates change tracking behavior.

1) Create the project and add EF Core SQLite provider

dotnet new webapi -n ChangeTrackingDemo
cd ChangeTrackingDemo

# Add SQLite provider (for a lightweight demo DB)
dotnet add package Microsoft.EntityFrameworkCore.Sqlite

You can also use SQL Server or InMemory provider — change the provider calls accordingly.

2) Add model and DbContext

Create Models/Person.cs:

using System.ComponentModel.DataAnnotations;

public class Person
{
    public int Id { get; set; }

    [Required]
    public string Name { get; set; }

    public int Age { get; set; }
}

Create Data/AppDbContext.cs:

using Microsoft.EntityFrameworkCore;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Person> People { get; set; }
}

3) Minimal Program.cs with endpoints that show tracking behavior

Replace Program.cs contents with the code below. Comments explain what to watch for in logs.

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Configure DbContext (SQLite file-based DB for demo)
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlite("Data Source=people.db"));

var app = builder.Build();

// Ensure DB is created and run a small demo on startup to print states to console
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    db.Database.EnsureCreated();

    // Clear for demo purposes
    db.People.RemoveRange(db.People);
    db.SaveChanges();

    var person = new Person { Name = "Vikas Gupta", Age = 30 };

    // 1) Add -> state should be Added
    db.People.Add(person);
    Console.WriteLine($"After Add: {db.Entry(person).State}"); // Added

    // 2) Save -> state becomes Unchanged
    db.SaveChanges();
    Console.WriteLine($"After SaveChanges: {db.Entry(person).State}"); // Unchanged

    // 3) Modify property

    person.Name = "Vikas Gupta Updated";

    // By default, EF Core uses snapshot change detection — call DetectChanges() to update states immediately
    db.ChangeTracker.DetectChanges();
    Console.WriteLine($"After property change + DetectChanges: {db.Entry(person).State}"); // Modified

    // Which properties were modified?
    var modifiedProps = db.Entry(person)
                         .Properties
                         .Where(p => p.IsModified)
                         .Select(p => p.Metadata.Name);
    Console.WriteLine("Modified properties: " + string.Join(", ", modifiedProps)); // Name

    // Save update
    db.SaveChanges();
    Console.WriteLine($"After SaveChanges (post-update): {db.Entry(person).State}"); // Unchanged
}

// Minimal API endpoints to experiment with
app.MapPost("/people", async (AppDbContext db, Person p) =>
{
    db.People.Add(p);
    Console.WriteLine($"POST -> entry state before save: {db.Entry(p).State}"); 

// Added
    await db.SaveChangesAsync();
    Console.WriteLine($"POST -> entry state after save: {db.Entry(p).State}"); // Unchanged
    return Results.Created($"/people/{p.Id}", p);
});

app.MapGet("/people/{id}", async (AppDbContext db, int id) =>
{
    var person = await db.People.FindAsync(id); // tracked query
    return person is null ? Results.NotFound() : Results.Ok(person);
});

// Demonstrate no-tracking: returned entity is not tracked
app.MapGet("/people/notracking/{id}", async (AppDbContext db, int id) =>
{
    var person = await db.People.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id);
    if (person == null) return Results.NotFound();

    // changing this object locally won't mark anything in ChangeTracker because it's detached
    person.Name += " (local change)";

    // If you call db.Entry(person) here, EF will create an Entry but it will be Detached unless you Attach it.
    return Results.Ok(new { Note = "This entity was loaded AsNoTracking; local changes are not tracked.", person });
});

// Attach a disconnected entity and mark single property modified (good for partial updates)
app.MapPatch("/people/{id}", async (AppDbContext db, int id, Person incoming) =>
{
    // Simulate receiving a DTO from client with only some properties set.
    var entity = new Person { Id = id }; // only key set
    db.People.Attach(entity); // attach as Unchanged
    // Suppose only Name should be updated:
    entity.Name = incoming.Name;
    db.Entry(entity).Property(e => e.Name).IsModified = true; // mark only Name
    await db.SaveChangesAsync();
    return Results.NoContent();
});

// Bulk insert demo: disable AutoDetectChanges for performance
app.MapPost("/people/bulk", async (AppDbContext db) =>
{
    db.ChangeTracker.AutoDetectChangesEnabled = false;
    for (int i = 0; i < 500; i++)
    {
        db.People.Add(new Person { Name = $"BulkPerson {i}", Age = 20 + (i % 30) });
    }

    // call DetectChanges once before save
    db.ChangeTracker.DetectChanges();
    await db.SaveChangesAsync();
    db.ChangeTracker.AutoDetectChangesEnabled = true;
    return Results.Ok("Bulk insert completed");
});

app.Run();

4) Run the app and test (commands)

dotnet run

Open another terminal and try:

  • Create a person:
curl -X POST http://localhost:5000/people -H "Content-Type: application/json" \
  -d '{"name":"Aman","age":28}' 
  • Get with tracking:
curl http://localhost:5000/people/1
  • Get with no-tracking:
curl http://localhost:5000/people/notracking/1
  • Partial update (PATCH):
curl -X PATCH http://localhost:5000/people/1 -H "Content-Type: application/json" \
  -d '{"name":"Aman Updated"}'

The actual port may differ; check the dotnet run console output for the precise URL(s).

What to observe in the example

  • After Add() the EntityEntry.State is Added. After SaveChanges() it becomes Unchanged.
  • When you change a tracked entity’s property, call ChangeTracker.DetectChanges() (or let EF do it automatically on SaveChanges) to update the entity state to Modified.
  • AsNoTracking() returns entities that do not inform the ChangeTracker of changes — ideal for read-only queries.
  • Attach() + Entry(…).Property(…).IsModified = true is the correct pattern for partial updates from disconnected clients (avoid marking the whole entity Modified unless you really want to update all columns).
  • For large bulk inserts, disable AutoDetectChangesEnabled, add entities, then call DetectChanges() once before SaveChanges() for a big performance boost.

Typical mistakes and best practices:

  • Don’t set modified state Entry.State For a disconnected like Entry.State = “Modified” unless you really want to update every column. Columns may be unexpectedly rewritten.
  • For read-only related queries operation, use AsNoTracking() in order to save CPU and memory.
  • When you are making partial updates in EF, set “IsModified = true” for only the properties that have changed and attach a stub with the key set.
  • Bulk operations: at the end, specifically you should call DetectChanges() and disable auto-detect changes.
  • Concurrency: use row version / timestamps to detect concurrent edits if multiple clients update the same rows.
  • Long-lived DbContext: keep DbContext short-lived (per-request in web apps). Long-lived contexts accumulate tracked objects and memory.

Short Common FAQ

Question: Does EF Core recognize changes to a property as soon as I set it?

Answer: EF Core use snapshot change detection by default, updating the CLR property when you set a property. However, the ChangeTracker compares snapshots during DetectChanges(), which is “the method that is automatically called before SaveChanges() or in some specific operations.” If you utilize the proxies service or implement INotifyPropertyChanged, EF will detect changes immediately.

Question: When should AsNoTracking() be used?

Answer: If you do not intend to modify the returned entities, use it for read-only queries. It is faster and consumes less memory.

Question: How can I edit a single field that a customer sends me?

Answer: Set the property value, attach an entity with the key set, and mark the property as modified:

var e = new Person { Id = id };
db.Attach(e);
e.Name = "new name";
db.Entry(e).Property(x => x.Name).IsModified = true;
await db.SaveChangesAsync();


Summary / Best practice checklist

  • You should prefer tracked queries for read->modify->save flows; prefer AsNoTracking for reads operation.
  • For disconnected updates, attach and explicitly mark modified properties.
  • Use ChangeTracker methods (Entries(), DetectChanges()) to inspect the state and modified properties when debugging.
  • Disable automatic detection during heavy inserts and re-enable it afterwards.

Leave a Reply

Your email address will not be published. Required fields are marked *