Tracking vs NoTracking Queries in EF Core: Performance Matters

If you have been working with EF Core for a while, you probably stumbled on something called tracking and no tracking queries. When I first encountered these, I honestly did not pay much attention. I thought EF Core was smart enough anyway, so I should just keep querying and saving changes like usual. But after handling real systems, one with more than one hundred thousand rows and lots of API requests per minute, I realized this topic is one of the most underrated performance switches in EF Core.

In this post, I want to explain this topic the way I wish somebody explained it to me many years ago. Not in a formal classroom way, but more in a coffee shop coding session with friends. I will share some lessons from real projects, problems I faced, and how tracking vs no tracking queries helped solve bottlenecks.

This will be a long and detailed post, almost like we are doing a knowledge transfer session. Mix of explanations, story time, and snippets. I want you to walk away with a solid understanding of when to use tracking, when not to use it, and how it affects your performance.

What Tracking Actually Means in EF Core

When EF Core tracks your entities, it stores them inside something called the Change Tracker. Think of it like a notebook EF Core uses to remember everything it has loaded. It watches for changes so it can generate correct UPDATE or DELETE statements later.

That means when you write something like:

C#
var product = await db.Products.FirstOrDefaultAsync(p => p.Id == id);
product.Name = "Updated Name";
await db.SaveChangesAsync();

EF Core already knows you changed the product even if you never called any special method. It tracked it. The problem is EF Core does not know if you really want to track or not. So by default, it tracks everything. In simple CRUD systems, this is fine. But in bigger systems with heavy reads, this becomes a performance issue.

NoTracking Means Just Give Me the Data

A NoTracking query simply loads the data and does not track the entity inside the Change Tracker. It behaves more like a lightweight read and forget.

This is super useful when the data is read only. For example:

C#
var products = await db.Products
    .AsNoTracking()
    .ToListAsync();

Once EF Core returns the result, it does not keep any record of these entities. It does not monitor them. It does not prepare to update them. It just returns the rows.

This makes the query faster, especially when you are loading hundreds or thousands of rows or when your API endpoint makes many queries.

A Story from a Real Project

I remember one system we built for an inventory management platform. One of the modules needed to load products with available quantities, supplier info, category info, and sometimes even promo details. When we launched our first beta test, we noticed the dashboard took around 1.4 seconds to load.

At first, it looked fine. Then clients started complaining. In production, one second feels like forever.

After running some profiling, we realized the Change Tracker was holding thousands of entities because of multiple queries loading related tables. Even if we never planned to update those results, EF Core still tracked them.

The fix was simple. We changed the queries to use AsNoTracking. After that, the dashboard went down to around 120 milliseconds. Massive improvement from a small change.

Example of Natural Tracking Use Case

Tracking is good when you need to update something. I like to write something like this:

C#
var item = await db.Products
    .FirstOrDefaultAsync(p => p.Id == id);

if (item == null) 
    return NotFound();

item.Price = newPrice;
item.UpdatedAt = DateTime.UtcNow;

await db.SaveChangesAsync();

This is a situation where tracking is perfect. You want EF Core to follow the changes of the entity.

Example of When You Really Should Use NoTracking

If you only want to display a list of items, or if your API endpoint serves data that will never be modified in that request, it is better to use NoTracking.

Example:

C#
var list = await db.Products
    .AsNoTracking()
    .Where(p => p.IsActive)
    .OrderBy(p => p.Name)
    .ToListAsync();

One of my clients used this pattern heavily for analytics dashboards. Those dashboards fetch thousands of rows but never update them. Using tracking here would have been wasted memory.

How EF Core Tracking Affects Performance

When EF Core tracks entities, it performs extra tasks. These include:

  1. Keeping a reference to the entity in the Change Tracker.
  2. Storing the original values.
  3. Monitoring any property changes.
  4. Checking if you loaded the same entity in the same context.
  5. Performing lookups when SaveChanges is executed.

These tasks are fine when updating a few items. But imagine loading five hundred or three thousand items. The Change Tracker will do a lot of extra work.

In one of my earlier projects, we had a nightly job that processed around twenty thousand rows and updated some data based on external calculations. Before optimizing, the job took almost five minutes. After switching the read queries to NoTracking, the runtime dropped to about one minute.

Most of the performance improvement came from removing unnecessary tracking.

How Queries Evolve into Business Logic

A lot of EF Core beginners start with simple queries like:

C#
var customers = await db.Customers.ToListAsync();

But then the requirements grow and the queries become bigger.

Maybe you now need joins:

C#
var details = await db.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items)
    .ToListAsync();

Then you add filtering:

C#
var list = await db.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items)
    .Where(o => o.Status == "Completed")
    .ToListAsync();

Then projections:

C#
var summary = await db.Orders
    .Where(o => o.Status == "Completed")
    .Select(o => new OrderSummaryDto
    {
        Id = o.Id,
        CustomerName = o.Customer.Name,
        ItemCount = o.Items.Count,
        Total = o.Items.Sum(i => i.Price)
    })
    .ToListAsync();

Then business logic:

C#
var result = await db.Orders
    .AsNoTracking()
    .Where(o => o.Status == "Completed" && o.OrderDate >= start && o.OrderDate <= end)
    .Select(o => new
    {
        o.Id,
        Customer = o.Customer.Name,
        Total = o.Items.Sum(i => i.Price),
        Discount = o.DiscountRate,
        FinalAmount = o.Items.Sum(i => i.Price) * (1 - o.DiscountRate)
    })
    .ToListAsync();

And believe me, when your project reaches this stage, the difference between tracking and no tracking really matters.

Common Mistakes Developers Make

I made many mistakes and it took me years to fully understand. These are the common ones I see:

1. Using tracking queries everywhere

This was my mistake in one HR and attendance analytics system. We loaded half a year of logs per user, and it was tracking everything. Once we switched to AsNoTracking, the endpoint went from around 800 milliseconds down to around 150 milliseconds.

2. Using no tracking in update operations

Some developers do this by accident:

C#
var employee = await db.Employees
    .AsNoTracking()
    .FirstOrDefaultAsync(e => e.Id == id);

employee.Name = "New Name";
await db.SaveChangesAsync();

This will not save anything because EF Core is not tracking the entity. So it does not know what changed.

3. Forgetting that projections already reduce tracking automatically

If you select into a DTO, EF Core does not track it.

C#
var dto = await db.Products
    .Select(p => new ProductDto { Id = p.Id, Name = p.Name })
    .ToListAsync();

This is automatically no tracking.

AsNoTrackingWithIdentityResolution

There are rare cases when you need no tracking but still want EF Core to avoid duplicating entities in the result.

For example:

C#
var list = await db.Orders
    .AsNoTrackingWithIdentityResolution()
    .Include(o => o.Customer)
    .ToListAsync();

I used this once for an API that loaded a list of orders but the Customer entity would repeat many times. Using identity resolution cleaned that up without enabling full tracking.

Default Query Behavior

If you want all queries in your DbContext to be NoTracking by default, you can configure it:

C#
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);

You can still override this on specific queries by calling AsTracking if you need updates.

I normally do this in systems where around 80 percent of queries are read only.

Real Example From One of My .NET Projects

We once had a multi tenant solution with dashboard widgets that loaded summaries from many tables. Every time a manager logged in, the dashboard executed more than ten queries with includes and groupings.

Trouble came when one tenant had more than a million transactions. Suddenly, some dashboard queries slowed to almost two seconds.

By analyzing queries with EF Profiler, we discovered that tracking was causing memory spikes and slow downs. After replacing the dashboard queries with NoTracking versions, everything became smooth again.

Here is one of the queries after refactoring:

C#
var monthlySales = await db.Sales
    .AsNoTracking()
    .Where(s => s.TenantId == tenantId && s.Date.Year == year)
    .GroupBy(s => s.Date.Month)
    .Select(g => new MonthlySalesDto
    {
        Month = g.Key,
        Total = g.Sum(x => x.Amount)
    })
    .ToListAsync();

This simple switch made the query almost four times faster.

Practical Guidelines Based on My Experience

These guidelines came from actual headaches I faced.

When to use tracking

  • When you will update the entity.
  • When you load an entity and pass it to SaveChanges later.
  • When you need concurrency tokens or audit fields to work automatically.

When to use no tracking

  • Reports, dashboards, data summaries.
  • Read only API endpoints that return large lists.
  • Queries using projections or anonymous types.
  • Background jobs analyzing large datasets.
  • Anything that involves thousands of rows and no updates.

Debugging Tip

If you are unsure whether a query is tracking or not, check this:

C#
var entries = db.ChangeTracker.Entries().Count();

Sometimes I do this while testing to be sure that my read only queries do not pile up entries.

Final Thoughts

Query tracking seems like a small EF Core feature, but in real enterprise systems, it becomes a performance game changer. I learned this the hard way in projects involving HR systems, POS transaction logs, multi tenant platforms, and dashboards with heavy data visualizations.

My hope is when you face similar challenges, you will remember this and apply the right tracking mode. Sometimes the simplest change gives the biggest win.


Sources

  1. Microsoft EF Core Docs
  2. EF Core Tracking Docs
  3. Performance Tips in EF Core
  4. Community discussions and samples from official .NET blog

Assi Arai
Assi Arai
Articles: 46