Skip to main content
SEMastery
Data Accessintermediate

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.

11 min readUpdated June 3, 2026

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:

  1. It creates the object in memory.
  2. It saves a copy of the original property values (the snapshot).
  3. 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.

The basic flow of change tracking, from loading data to saving it

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.

StateMeaningWhat SaveChanges does
AddedNew entity, not in the database yetRuns an INSERT
ModifiedAt least one property changedRuns an UPDATE
DeletedMarked for removalRuns a DELETE
UnchangedTracked, but nothing changedDoes nothing
DetachedNot tracked at allDoes 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.

How an entity moves between states during its life

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); // Unchanged

Why 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

Load rows
Build snapshots
Track entities
Display only

Steps

1

Load rows

5000 products fetched

2

Build snapshots

Memory per row

3

Track entities

Identity map grows

4

Display only

No save happens

Each tracked row adds memory and CPU work even when you never save

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 quick decision guide for choosing tracking or no-tracking

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.

MethodTracks changes?Same row, same object?Speed
Default queryYesYesSlowest
AsNoTrackingWithIdentityResolutionNoYesMedium
AsNoTrackingNoNo (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 on

Be 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

Disable auto-detect
Add range
Save once
Re-enable

Steps

1

Disable auto-detect

Stop rescans

2

Add range

Loop adds rows

3

Save once

One detect pass

4

Re-enable

Restore safety

Disable auto-detect, add in a loop, save once, then re-enable

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.

What happens step by step when you call SaveChanges

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, or Detached.
  • SaveChanges calls DetectChanges, then turns each state into the matching INSERT, UPDATE, or DELETE.
  • Tracking costs memory and CPU. For read-only queries, use AsNoTracking to skip that cost.
  • Use AsNoTrackingWithIdentityResolution when 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

Related Posts