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);
});
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. 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();
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();
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();
EF sees:
- An untracked
Order - A referenced
Customerobject - 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 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();
- 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;
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();
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();
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();
EF tries to insert an existing role.
4. Attach Updates Only Modified Columns
context.Attach(user);
user.IsActive = false;
await context.SaveChangesAsync();
Clean and safe when used intentionally.
5. Update Forces Full-Row Update
context.Update(user);
await context.SaveChangesAsync();
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();
}
- 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();
}
- 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();
}
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();
}
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)