Fixed: A Retried EF Core Transaction Saves the Row Twice
The connection dropped during a transaction, EF Core retried it as it should, and the request created two rows, with different names, from one request. MySQL had rolled the first attempt back. The extra row came from my DbContext, which still remembered the first attempt's entity.
What I Was Doing
My .NET C# Web API uses EF Core 10.0.14, with a DbContext from dependency injection and a retrying execution strategy. Each attempt adds a category and changes a product:
await strategy.ExecuteAsync(
async ct =>
{
await using var transaction = await db.Database.BeginTransactionAsync(ct);
db.Categories.Add(new Category { Name = $"Retry {Guid.NewGuid():N}"[..14], CreatedAtUtc = clock.GetUtcNow().UtcDateTime });
var product = await db.Products.FirstAsync(p => p.Id == id, ct);
product.ReorderLevel += 1;
await db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
},
cancellationToken);
The connection dropped while the first attempt was saving.
What I Expected
One retry, and one new category.
What I Got
{"attempts":2,"saved":["Retry 275b98d6","Retry 28d05299"]}

Two attempts, as expected, and two rows, which wasn't.
What I Checked
The log. The first attempt failed, and the retry ran:
Attempt 1
A transient exception occurred during execution. The operation will be retried after 0ms.
Attempt 2
Whether MySQL kept the first attempt. It didn't: the connection dropped before the commit, so MySQL rolled it back. Both rows came from the second attempt's one SaveChangesAsync.
The EF Core docs. The sample on the connection resiliency page creates a new DbContext inside the delegate, so there's nothing left over from an earlier attempt. My DbContext comes from dependency injection, and it lives for the whole request.
Why It Happens
Add puts the entity in the DbContext's change tracker as Added. A failed SaveChangesAsync doesn't take it out. My delegate then ran again on the same DbContext, and added another. The second SaveChangesAsync inserted everything still marked Added, which was both.
The Fix
Start every attempt with an empty tracker:
await strategy.ExecuteAsync(
async ct =>
{
db.ChangeTracker.Clear();
await using var transaction = await db.Database.BeginTransactionAsync(ct);
// ...
ChangeTracker.Clear stops tracking everything, so each attempt works as if it were the first.
After the Fix
{"attempts":2,"saved":["Retry df418ebd"]}

Two attempts and one row.
Common Mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
| Adding entities outside the delegate | A retry can't redo that part | Put the whole unit inside |
Calling Clear only in a catch | Easy to miss a path | Call it first, in every attempt |
| Using entities tracked before the strategy | Clear detaches them too | Load what you need inside |
| Assuming this covers a lost commit | That's a different problem | Add a key |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Two rows, and the first attempt committed | A lost commit | The fix |
| A counter goes up by 2 | The change was applied on an entity kept from before | Load it inside the delegate |
Duplicate entry on the retry | The leftover entity hits a unique index | The same fix |
Conclusion
A retry runs your delegate again on the same DbContext, with the first attempt's entities still in it. Start each attempt with db.ChangeTracker.Clear(), or create a new context inside the delegate.
Additional Resources
- Connection Resiliency — the sample with a new context.
- Change Tracking — what the tracker keeps.
