EF Core Change Tracking: The Bug Factory You Accidentally Built

Published: (January 19, 2026 at 08:25 AM EST)
7 min read
Source: Dev.to

Source: Dev.to

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(options =>
{
    options.UseSqlServer(conn);
    // Applies everywhere
    options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});

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();

After disabling tracking globally, the same code throws:

Cannot insert duplicate key row in object 'Customers'
The duplicate key value is (...)

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

This is not an EF Core bug. It 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:

  1. Compares tracked entities against their original state
  2. Generates SQL only for what changed
  3. 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();

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 wantCommands want
SpeedState awareness
Low memory usageRelationship handling
No side effectsCorrect updates

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


Why SaveChanges() Does Nothing on Detached Entities

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

user.Name = "New Name";

await context.SaveChangesAsync();

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

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

order.Customer = existingCustomer;

context.Orders.Update(order);
await context.SaveChangesAsync();

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;
  • 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;
  • EF assumes the entity already 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);
await context.SaveChangesAsync();

// Generated SQL:
// UPDATE [Products] SET [Price] = @p0 WHERE [Id] = @p1;
  • EF marks the whole entity as Modified
  • All scalar properties are sent to the database (even if unchanged)
  • Use when you have a detached entity and want EF to treat it as existing but you don’t care about change detection

Takeaways

  1. Leave tracking on for write‑oriented operations.
  2. Use AsNoTracking() only for true read‑only scenarios.
  3. When you need to work with detached data, explicitly Attach or Update the entity, not both.
  4. Avoid a “global no‑tracking” setting unless you are certain the application is read‑only.

By understanding what EF Core is doing under the hood, you can stop treating AsNoTracking() as a panic button and start using it as the performance tool it was designed to be.

context.Update(product); // This forces all columns to be updated
await context.SaveChangesAsync();

What Happens When You Call Update()?

  • EF marks all properties as modified
  • Generates a full‑row UPDATE statement
  • Produces large SQL statements
  • Overwrites columns you didn’t touch
UPDATE [Products]
SET [Price] = @p0,
    [Name] = @p1,
    [Description] = @p2,
    [Stock] = @p3,
    [CategoryId] = @p4
WHERE [Id] = @p5;

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();

Result: Correct update with 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();

Result: 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();

Result: EF tries to insert an existing role → duplicate‑key error.


4. Attach Updates Only Modified Columns

context.Attach(user);
user.IsActive = false;
await context.SaveChangesAsync();

Result: Clean and safe when used intentionally.


5. Update Forces Full‑Row Update

context.Update(user);
await context.SaveChangesAsync();

Result: All columns are marked modified → bigger SQL, higher risk.


The Real Fix: Stop Toggling Tracking

The problem isn’t EF Core itself; it’s mixing read and write intent in the same data‑access path.
The solution is structural: split repositories by intent.


Read‑Only Repository

public class OrderReadRepository
{
    private readonly DbContext _context;

    public OrderReadRepository(DbContext context) => _context = context;

    public Task<OrderDto> GetById(Guid id) =>
        _context.Orders
                .AsNoTracking()
                .Where(o => o.Id == id)
                .Select(o => new OrderDto(/* … */))
                .FirstAsync();
}

Characteristics

  • Always no‑tracking
  • Safe by design
  • Easy to cache
  • Ready for a read replica later

Read‑Write Repository

public class OrderWriteRepository
{
    private readonly DbContext _context;

    public OrderWriteRepository(DbContext context) => _context = context;

    public Task<Order> GetTracked(Guid id) =>
        _context.Orders.FirstAsync(o => o.Id == id);

    public Task Save() => _context.SaveChangesAsync();
}

Characteristics

  • Tracking is expected
  • Relationships work correctly
  • Updates are accurate
  • No flags, no guessing

Centralised Base Repositories (Optional)

Read‑Only Base

public abstract class ReadOnlyRepository<TEntity> where TEntity : class
{
    protected readonly DbContext _context;
    protected readonly DbSet<TEntity> _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();
}

Read‑Write Base

public abstract class ReadWriteRepository<TEntity> where TEntity : class
{
    protected readonly DbContext _context;
    protected readonly DbSet<TEntity> _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 SaveChangesAsync() => _context.SaveChangesAsync();
}

Why This Isn’t Just “Clean‑Code Overhead”

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

Application‑Layer Usage

  • GET endpoints inject a read‑only repository.
  • Command handlers inject a write repository.

This pattern scales naturally:

  • Enables read replicas, caching layers, or separate databases later.
  • You don’t need full CQRS; you just need to respect read vs. write intent.

Lessons Learned

  • Tracking is powerful but expensive.
  • AsNoTracking 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 don’t need full CQRS to think this way—just stop pretending reads and writes are the same thing. EF Core wasn’t the problem; the defaults were. Once you understand that, the bugs you ignored for years suddenly make sense.

Back to Blog

Related posts

Read more »

Database Transaction Leak

Introduction We often talk about memory leaks, but there is another silent performance killer in backend development: Database Transaction Leaks. I recently sp...