DEV Community

Cover image for EF Core Change Tracking: The Bug Factory You Accidentally Built
Paula Fahmy
Paula Fahmy

Posted on

EF Core Change Tracking: The Bug Factory You Accidentally Built

Most production EF Core problems do not start with broken code.
They start with code that works.

Queries return data. Updates succeed. Performance looks acceptable. Then traffic grows, memory spikes, SQL logs explode, or worse, random primary key violations start appearing in places no one touched. Someone flips AsNoTracking() on or off, the system stabilizes, and the team moves on.

Until it happens again (somewhere else).

This article is about how we got there, why those bugs happen, and how to design your data access so you stop toggling tracking like a panic button.


How the Story Usually Starts

EF Core enables change tracking by default.

That sounds reasonable. You query entities, you modify them, you call SaveChanges(). EF figures out what changed and generates SQL. Simple.

That default quietly works its way into production.

Every query tracks entities. Long-lived DbContexts accumulate them. Memory usage grows. GC pressure increases. Latency creeps up. Someone profiles the app and notices thousands of tracked entities sitting in memory doing nothing.

The fix seems obvious.

services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(conn);
    // Applies everywhere
    options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});
Enter fullscreen mode Exit fullscreen mode

Performance improves immediately. Memory drops. Everyone relaxes.

Then the first relationship update fails with a duplicate key exception.


The Bug You Have Seen and Ignored

Before the change, this worked:

var order = await context.Orders
    .Include(o => o.Customer)
    .FirstAsync(o => o.Id == orderId);

order.Customer = existingCustomer;

await context.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

After disabling tracking globally, the same code throws:

Cannot insert duplicate key row in object 'Customers'
The duplicate key value is (...)
Enter fullscreen mode Exit fullscreen mode

Nothing in the code changed. The database did not change. Only tracking did.

This is not an EF Core bug. This is EF Core doing exactly what you asked, just not what you expected.


What Change Tracking Actually Is

Change tracking is EF Core keeping an internal graph of entities it is responsible for.

Tracked entities have:

  • A known identity (primary key)
  • A known state (Unchanged, Modified, Added, Deleted)
  • Snapshot or proxy-based detection of changes

When you call SaveChanges(), EF:

  • Compares tracked entities against their original state
  • Generates SQL only for what changed
  • Maintains relationship consistency automatically

When an entity is not tracked, EF does none of this.

No state. No identity map. No relationship awareness.


Why AsNoTracking() Exists

AsNoTracking() tells EF:

"This data is read-only. Do not waste memory or CPU tracking it."

That is correct and valuable for:

  • Large result sets
  • Read-heavy endpoints
  • Reporting
  • Projections that will never be saved

Example:

var orders = await context.Orders
    .AsNoTracking()
    .Where(o => o.Status == Status.Open)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

This is faster, leaner, and safer than tracking by default. Nothing is kept in memory for later usage (like when using tracking).

The mistake is using it everywhere without understanding the consequences.


Commands and Queries Are Not the Same Thing

Queries want:

  • Speed
  • Low memory usage
  • No side effects

Commands want:

  • State awareness
  • Relationship handling
  • Correct updates

Using the same tracking strategy for both is where most systems break.


Why SaveChanges() Does Nothing on Detached Entities

Consider this:

var user = await context.Users
    .AsNoTracking()
    .FirstAsync(u => u.Id == id);

user.Name = "New Name";

await context.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

No error. No update. Nothing happens.

Why?

Because EF is not tracking user. From EF’s perspective, nothing changed because nothing was being watched.

This is a silent failure. The worst kind.


The Relationship Trap That Causes Duplicate Keys

This one is more dangerous.

var order = await context.Orders
    .AsNoTracking()
    .FirstAsync(o => o.Id == orderId);

order.Customer = existingCustomer;

context.Orders.Update(order);
await context.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

EF sees:

  • An untracked Order
  • A referenced Customer object
  • No tracking information for either

So it assumes both are new.

EF generates:

  • INSERT for Order
  • INSERT for Customer

If the customer already exists, the database rejects it.

This is why disabling tracking globally often "fixes performance" and then breaks relationships.


Attach vs Update vs Already Tracked

These three are not interchangeable.

Tracked Entity (Best Case)

var product = await context.Products.FirstAsync(p => p.Id == id);
product.Price += 10;

await context.SaveChangesAsync();

// Generated SQL:
// UPDATE [Products] SET [Price] = @p0 WHERE [Id] = @p1;
Enter fullscreen mode Exit fullscreen mode
  • EF tracks changes
  • Only modified columns are updated
  • Minimal SQL

Attach

var product = await context.Products.AsNoTracking().FirstAsync(p => p.Id == id);
context.Attach(product);

product.Price += 10;

await context.SaveChangesAsync();

// Generated SQL: // UPDATE [Products] SET [Price] = @p0 WHERE [Id] = @p1;
Enter fullscreen mode Exit fullscreen mode
  • EF assumes entity exists
  • Only modified properties are updated
  • Safe when you know the entity is not new

Update

var product = await context.Products.AsNoTracking().FirstAsync(p => p.Id == id);
product.Price += 10;
context.Update(product); // This forces all columns to be updated
await context.SaveChangesAsync();

Enter fullscreen mode Exit fullscreen mode
  • EF marks all properties as modified
  • Generates full-row UPDATE
  • Large SQL statements
  • Overwrites columns you did not touch
UPDATE [Products]
SET [Price] = @p0,
    [Name] = @p1,
    [Description] = @p2,
    [Stock] = @p3,
    [CategoryId] = @p4
WHERE [Id] = @p5;
Enter fullscreen mode Exit fullscreen mode

Calling Update() on an already tracked entity is unnecessary and wasteful. EF already knows what changed.


Practical Demo Scenarios

1. Tracked Update Works

var user = await context.Users.FirstAsync(u => u.Id == id);
user.Email = "new@email.com";
await context.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

Correct update. Minimal SQL.


2. AsNoTracking Causes Silent No-Op

var user = await context.Users
    .AsNoTracking()
    .FirstAsync(u => u.Id == id);

user.Email = "new@email.com";
await context.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

No update. No error. No warning.


3. AsNoTracking + Add Causes Duplicate Key Failure

var role = await context.Roles
    .AsNoTracking()
    .FirstAsync(r => r.Id == roleId);

context.Roles.Add(role);
await context.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

EF tries to insert an existing role.


4. Attach Updates Only Modified Columns

context.Attach(user);
user.IsActive = false;
await context.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

Clean and safe when used intentionally.


5. Update Forces Full-Row Update

context.Update(user);
await context.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

All columns marked modified. Bigger SQL. Higher risk.


The Real Fix: Stop Toggling Tracking

The root problem was not EF Core.
It was mixing read and write intent in the same data access path.

The fix was structural.


Splitting Repositories by Intent

Instead of enabling and disabling tracking everywhere, we split repositories.

Read-Only Repository

public class OrderReadRepository
{
    public Task<OrderDto> GetById(Guid id) =>
        context.Orders
            .AsNoTracking()
            .Where(o => o.Id == id)
            .Select(o => new OrderDto(...))
            .FirstAsync();
}
Enter fullscreen mode Exit fullscreen mode
  • Always no tracking
  • Safe by design
  • Easy to cache
  • Easy to move to a read replica later

Read-Write Repository

public class OrderWriteRepository
{
    public Task<Order> GetTracked(Guid id) =>
        context.Orders.FirstAsync(o => o.Id == id);

    public Task Save() => context.SaveChangesAsync();
}
Enter fullscreen mode Exit fullscreen mode
  • Tracking is expected
  • Relationships work
  • Updates are correct

No flags. No guessing.

One more good idea is to have this kind of configuration set once and for all:

public abstract class ReadOnlyRepository where TEntity : class
{

protected readonly DbContext _context;protected readonly DbSet _dbSet;

protected ReadOnlyRepository(DbContext context)
{
    _context = context;
    _dbSet = context.Set<TEntity>();
    _context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}

public virtual IQueryable<TEntity> GetAll() => _dbSet;
public virtual Task<TEntity> GetByIdAsync(object id) => _dbSet.FindAsync(id).AsTask();

}
Enter fullscreen mode Exit fullscreen mode
public abstract class ReadWriteRepository where TEntity : class
{

protected readonly DbContext _context;protected readonly DbSet _dbSet;

protected ReadWriteRepository(DbContext context)
{
    _context = context;
    _dbSet = context.Set<TEntity>();
    _context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.TrackAll;
}

public virtual IQueryable<TEntity> GetAll() => _dbSet;
public virtual Task<TEntity> GetByIdAsync(object id) => _dbSet.FindAsync(id).AsTask();

public virtual void Add(TEntity entity) => _dbSet.Add(entity);
public virtual void Update(TEntity entity) => _dbSet.Update(entity);
public virtual void Remove(TEntity entity) => _dbSet.Remove(entity);
public virtual Task<int> SaveChangesAsync() => _context.SaveChangesAsync();

}
Enter fullscreen mode Exit fullscreen mode

Why This Is Not Just Clean Code Overhead

This approach:

  • Eliminates accidental tracking
  • Prevents silent no-ops
  • Prevents duplicate key bugs
  • Makes performance predictable
  • Makes intent explicit

At the application layer, it aligns naturally.

A GET endpoint injects a read-only repository.
A command handler injects a write repository.

This pattern scales upward.
It enables read replicas, caching layers, and even separate databases later.

Not because you planned CQRS.
Because you respected intent.


Lessons Learned

  • Tracking is powerful but expensive
  • NoTracking is safe only for reads
  • SaveChanges does nothing for detached entities
  • Update is a blunt instrument
  • Attach is precise when used correctly
  • Repository design must reflect query vs command intent

You do not need full CQRS to think this way.
You just need to stop pretending reads and writes are the same thing.

EF Core was never the problem.
The defaults were.

Once you understand that, the bugs you ignored for years suddenly make sense.

Top comments (0)