EF Core Change Tracking: The Bug Factory You Accidentally Built
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:
- 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();
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 | Commands want |
|---|---|
| Speed | State awareness |
| Low memory usage | Relationship handling |
| No side effects | Correct 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
Customerobject - No tracking information for either
So it assumes both are new.
EF generates:
INSERTforOrderINSERTforCustomer
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
- Leave tracking on for write‑oriented operations.
- Use
AsNoTracking()only for true read‑only scenarios. - When you need to work with detached data, explicitly
AttachorUpdatethe entity, not both. - 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.
AsNoTrackingis safe only for reads.SaveChangesdoes nothing for detached entities.Updateis a blunt instrument.Attachis 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.