Understanding Change Tracking for Better Performance in EF Core
Learn how EF Core change tracking works, the entity states it uses, and simple tricks like AsNoTracking to make your .NET apps faster.
Imagine you run a small grocery shop. Every morning you copy your shelf list into a notebook. Through the day, customers buy things and you add new stock. At closing time, you compare the shelf with your morning notebook. You only update the items that actually changed. You do not rewrite the whole list. That morning snapshot is doing a lot of quiet work for you.
EF Core does the exact same thing. When it loads data from your database, it keeps a quiet snapshot in memory. Later, when you ask it to save, it compares the current data to that snapshot and writes only what changed. This system is called change tracking. It is helpful, but it is not free. If you understand how it works, you can make your apps much faster.
This article explains change tracking in simple words. We will look at entity states, how SaveChanges decides what to do, and the small habits that keep your app fast.
What change tracking actually does
When you load an entity through a normal EF Core query, the DbContext does three things:
- It creates the object in memory.
- It saves a copy of the original property values (the snapshot).
- It starts watching that object for changes.
Later, when you call SaveChanges, EF Core runs a step called DetectChanges. It walks through every tracked entity, compares the current values to the snapshot, and figures out what SQL to send. Only changed rows are written.
This is great when you want to edit data. You change a property, call SaveChanges, and EF Core works out the UPDATE statement for you. You never write SQL by hand. But every snapshot uses memory, and every DetectChanges call takes time. When you load thousands of rows just to show them on a screen, all that tracking work is wasted.
The five entity states
Every tracked entity has a state. The state tells EF Core what to do at save time. There are five states, and they are easy to remember.
| State | Meaning | What SaveChanges does |
|---|---|---|
Added | New entity, not in the database yet | Runs an INSERT |
Modified | At least one property changed | Runs an UPDATE |
Deleted | Marked for removal | Runs a DELETE |
Unchanged | Tracked, but nothing changed | Does nothing |
Detached | Not tracked at all | Does nothing |
When you load an entity with a normal query, it starts as Unchanged. The moment you change a property, EF Core moves it to Modified. New objects you add become Added. Objects you remove become Deleted. Anything EF Core is not watching is Detached.
Notice the nice loop. After you save, Added and Modified entities settle back into Unchanged, ready for the next round. Deleted entities leave the tracker and become Detached. This is the same as your grocery notebook: after you finish the evening update, the new list becomes tomorrow's starting point.
You can check a state yourself at any time. This is very handy when you want to understand why a save did or did not happen.
var product = await db.Products.FirstAsync(p => p.Id == 1);
// Just loaded: state is Unchanged
Console.WriteLine(db.Entry(product).State); // Unchanged
product.Price = 99.0m;
// After editing: state is Modified
Console.WriteLine(db.Entry(product).State); // Modified
await db.SaveChanges();
// After saving: back to Unchanged
Console.WriteLine(db.Entry(product).State); // UnchangedWhy tracking can slow you down
Tracking is not magic. For every entity it tracks, EF Core has to:
- Allocate memory for the snapshot of original values.
- Keep internal lookup tables so it can find entities by key (this is called identity resolution).
- Run comparison logic for every property during
DetectChanges.
For a handful of rows, you will never feel this. But picture an API endpoint that loads 5,000 products just to show them on a page. EF Core builds 5,000 snapshots and tracks 5,000 objects. You never edit any of them. All that work is pure waste. The page uses more memory and responds more slowly than it needs to.
Cost of tracking a read-only list
Steps
Load rows
5000 products fetched
Build snapshots
Memory per row
Track entities
Identity map grows
Display only
No save happens
This is the most common EF Core performance mistake. The good news is that the fix is one short method.
AsNoTracking: the read-only fast lane
When you only want to read data and show it, tell EF Core not to track it. You do this with AsNoTracking. It returns a query where no entity is tracked. No snapshots are built. No identity map grows. The query runs faster and uses less memory.
// Read-only: fast, no tracking
var products = await db.Products
.AsNoTracking()
.Where(p => p.IsActive)
.ToListAsync();
// These objects are Detached.
// Editing them does nothing at SaveChanges time.There is one rule you must remember. If you change an entity loaded with AsNoTracking, the change tracker never sees it, so SaveChanges will not write it. That is the whole point. Only use AsNoTracking when you do not plan to update the data you loaded.
Here is a simple way to decide.
A good habit: make read-only endpoints use AsNoTracking, and keep tracking only for endpoints that create, update, or delete data. Most web apps read far more than they write, so this one habit pays off a lot.
AsNoTrackingWithIdentityResolution
Sometimes a no-tracking query returns the same row more than once, for example through a join. Plain AsNoTracking may create two separate objects for the same row. If you need the same row to map to the same object, but still without tracking, use AsNoTrackingWithIdentityResolution. It costs a little more than AsNoTracking but still skips the snapshot and the full tracking overhead.
| Method | Tracks changes? | Same row, same object? | Speed |
|---|---|---|---|
| Default query | Yes | Yes | Slowest |
AsNoTrackingWithIdentityResolution | No | Yes | Medium |
AsNoTracking | No | No (may duplicate) | Fastest |
Turning off tracking for a whole context
If a DbContext is used only for reading, you can switch its default behavior once instead of writing AsNoTracking everywhere. Set the tracking behavior when you configure the context.
public class ReadOnlyDbContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
// Every query in this context skips tracking by default.
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
}After this, every query in that context behaves like AsNoTracking. If a single query in that context does need tracking, you can flip it back with .AsTracking() on just that query. This is a clean pattern when you split reads and writes into separate contexts.
DetectChanges and bulk work
DetectChanges is the engine behind SaveChanges. By default, EF Core calls it automatically at the right moments. For normal apps, leave this alone. It keeps everything correct without you thinking about it.
But there is one case where it hurts: adding or updating a very large number of entities in a loop. EF Core may run DetectChanges again and again, and the cost grows with the number of tracked entities. A loop that adds 50,000 rows can crawl because each step rescans everything already tracked.
For these heavy batch jobs, you can switch off automatic detection, do your work, and then save once.
db.ChangeTracker.AutoDetectChangesEnabled = false;
foreach (var row in bigList)
{
db.Products.Add(new Product { Name = row.Name });
}
await db.SaveChanges(); // EF detects changes once here
db.ChangeTracker.AutoDetectChangesEnabled = true; // turn it back onBe careful with this switch. If you turn it off, EF Core may miss edits you make to already-tracked objects, because it is no longer watching them automatically. Always turn it back on (or call db.ChangeTracker.DetectChanges() yourself) before querying or saving in the normal way.
Fast bulk insert pattern
Steps
Disable auto-detect
Stop rescans
Add range
Loop adds rows
Save once
One detect pass
Re-enable
Restore safety
For truly large jobs (tens of thousands of rows or more), a dedicated bulk library that uses SqlBulkCopy is far faster, because it bypasses the change tracker completely. The trade-off is that interceptors, query filters, and audit logic do not run, so use it only when you understand that.
Clearing the tracker between batches
When you process many records in batches with the same long-lived context, the tracker keeps growing. Even saved entities stay in memory as Unchanged. Over time this slows every DetectChanges call and uses more memory.
The clean fix is to clear the tracker after each batch.
foreach (var batch in batches)
{
foreach (var item in batch)
db.Orders.Add(item);
await db.SaveChanges();
// Forget everything; start the next batch clean.
db.ChangeTracker.Clear();
}ChangeTracker.Clear() detaches all entities at once. It is much faster than detaching them one by one, and it keeps memory flat across a long job. In most modern apps, though, the simplest answer is to use a short-lived DbContext per unit of work, so the tracker never gets a chance to grow.
A mental model of SaveChanges
It helps to picture the whole save in one diagram. When you call SaveChanges, EF Core detects changes, groups entities by state, sends the matching SQL inside a transaction, and then resets the states.
Once you can see this flow in your head, change tracking stops feeling like magic. You know what each state means, you know when work is wasted, and you know the small levers that speed things up.
Common mistakes to avoid
Here are the traps that catch most people.
- Tracking read-only lists. If you load data only to display it, add
AsNoTracking. This is the single biggest easy win. - Editing AsNoTracking entities. Changes to no-tracking entities are silently ignored at save time. Match your tracking choice to your intent.
- One giant context for a batch job. A tracker holding 100,000 entities makes every operation slow. Clear it or use short contexts.
- Turning off auto-detect and forgetting to turn it back on. This causes confusing bugs where edits do not save. Always restore it.
- Reaching for bulk libraries too early. They skip filters, interceptors, and audit logic. Use them only for genuinely large jobs and only when you accept that trade-off.
Quick recap
- Change tracking keeps a snapshot of loaded entities so EF Core can save only what changed.
- Every tracked entity has a state:
Added,Modified,Deleted,Unchanged, orDetached. SaveChangescalls DetectChanges, then turns each state into the matchingINSERT,UPDATE, orDELETE.- Tracking costs memory and CPU. For read-only queries, use
AsNoTrackingto skip that cost. - Use
AsNoTrackingWithIdentityResolutionwhen you need the same row to map to the same object without tracking. - For big batch jobs, disable
AutoDetectChangesEnabled, save once, and re-enable it; or use a bulk library that bypasses the tracker. - Use
ChangeTracker.Clear()or short-lived contexts so the tracker never grows too large.
References and further reading
- Change Tracking in EF Core (Microsoft Learn)
- Tracking vs. No-Tracking Queries (Microsoft Learn)
- Change Detection and Notifications (Microsoft Learn)
- Tracking vs. No-Tracking Queries in EF Core (codewithmukesh)
- Understanding Change Tracking for Better Performance in EF Core (antondevtips)
Related Posts
EF Core Bulk Insert: Boost Performance with Entity Framework Extensions
Learn how EF Core bulk insert with Entity Framework Extensions saves data faster, using simple examples, diagrams, and clear performance comparisons.
The Correct Way to Use Batch Update and Batch Delete in EF Core
Learn the correct, safe way to use ExecuteUpdate and ExecuteDelete batch methods in EF Core, with transactions, change tracker tips, and EF Core 10 features.
Optimizing Bulk Database Updates in .NET: A Practical Guide
Learn how to make bulk database updates fast in .NET using EF Core ExecuteUpdate, batching, and transactions, with simple examples and EF Core 10 tips.
5 EF Core Features You Need to Know (Beginner Friendly)
Learn 5 must-know EF Core features with simple examples: change tracking, AsNoTracking, eager loading, bulk ExecuteUpdate, and query filters.
Calling Views, Stored Procedures and Functions in EF Core
A friendly, beginner guide to calling database views, stored procedures, and functions in EF Core with FromSql, SqlQuery, ExecuteSql, and ToView.
The Real Cost of Returning the Identity Value in EF Core
Why EF Core asking the database for the new Id after every insert costs round trips, and how HiLo, sequences, and Guids cut that cost down.