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.
- EntityState — Added, 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 runconsole 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.