How to Use a Transaction with a Retrying Execution Strategy in EF Core
As soon as a DbContext has a retrying execution strategy, a plain BeginTransactionAsync stops working. The strategy has to know the whole group of statements, so it can run them all again. It only takes a wrapper, with one detail in the lambda and one at the top of it.
Prerequisites
- A
DbContextwith a retrying strategy. How to Make EnableRetryOnFailure Retry MySQL Errors in EF Core or a custom one. I used EF Core 10.0.14. - A transaction you've already written. How to Use a Transaction in EF Core with BeginTransactionAsync.
Step 1: See Why It's Needed
A transaction outside the strategy fails on the first statement inside it:
System.InvalidOperationException: The configured execution strategy 'MySQLRetryingExecutionStrategy' does not support user-initiated transactions. Use the execution strategy returned by 'DbContext.Database.CreateExecutionStrategy()' to execute all the operations in the transaction as a retriable unit.
Fixed: "The configured execution strategy does not support user-initiated transactions" has the whole story.
Step 2: Wrap the Transaction
Ask the context for its strategy, and hand it a delegate with everything in it:
var strategy = db.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(
async Task<Results<Created<StockChangeResponse>, NotFound>> (CancellationToken ct) =>
{
var now = clock.GetUtcNow().UtcDateTime;
// Nothing below is permanent until CommitAsync. Leaving this method without
// committing rolls everything back, when "await using" disposes of the transaction.
await using var transaction = await db.Database.BeginTransactionAsync(ct);
// First write: add to the quantity on hand.
var changed = await db.Products
.Where(p => p.Id == productId)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(p => p.QuantityOnHand, p => p.QuantityOnHand + request.Quantity)
.SetProperty(p => p.UpdatedAtUtc, now),
ct);
if (changed == 0)
{
return TypedResults.NotFound();
}
// Second write: record why it changed.
var movement = new StockMovement
{
ProductId = productId,
Type = StockMovementType.Receipt,
QuantityChange = request.Quantity,
Reason = request.Reason,
Reference = request.Reference,
OccurredAtUtc = now,
};
db.StockMovements.Add(movement);
await db.SaveChangesAsync(ct);
var quantityOnHand = await db.Products
.Where(p => p.Id == productId)
.Select(p => p.QuantityOnHand)
.FirstAsync(ct);
await transaction.CommitAsync(ct);
return TypedResults.Created(
$"/api/v1/products/{productId}/stock/movements",
new StockChangeResponse(ToResponse(movement), quantityOnHand));
},
cancellationToken);

Three things matter here:
- Everything is inside.
BeginTransactionAsync, all the work, andCommitAsync. If a retry happens, this whole delegate runs again. - The lambda states its return type.
async Task<Results<...>> (CancellationToken ct) =>. The tworeturns have different types, and without this you getCS8031. - It uses
ct, not the outer token. The strategy passes one in, along withcancellationTokenat the end.
Step 3: Run It
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /api/v1/products/9/stock/movements
{
"movement": {
"id": 2,
"productId": 9,
"type": "Receipt",
"quantityChange": 5,
"reason": "Second delivery",
"reference": "PO-1003",
"orderId": null,
"occurredAtUtc": "2026-11-05T05:12:38.543732Z"
},
"quantityOnHand": 15
}
dbug: Microsoft.EntityFrameworkCore.Database.Transaction[20200]
Began transaction with isolation level 'Unspecified'.
dbug: Microsoft.EntityFrameworkCore.Database.Transaction[20202]
Committed transaction.
And the two writes agree:
+----------------+----------------+
| QuantityOnHand | SumOfMovements |
+----------------+----------------+
| 15 | 15 |
+----------------+----------------+
Step 4: Make Every Attempt Start Clean
The delegate may run more than once on the same DbContext. Two kinds of leftovers matter:
Entities from the failed attempt. Start with db.ChangeTracker.Clear(), or a retry can save a row twice:
return await strategy.ExecuteAsync(
async Task<Results<Created<StockChangeResponse>, NotFound>> (CancellationToken ct) =>
{
db.ChangeTracker.Clear();
await using var transaction = await db.Database.BeginTransactionAsync(ct);
// ...
Work that isn't in the database. A second email or a second charge won't roll back. Do those after ExecuteAsync returns.
If a repeat could apply the work twice because the first attempt really did commit, you also need a key.
Common Mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
| Beginning outside the delegate | The same error | Begin inside |
| Sending an email in the delegate | A retry sends it again | Send it afterwards |
| Using a variable that keeps a total across attempts | It counts the failed attempt too | Compute it inside, or reset it |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
error CS8031 | The lambda has no return type | The fix |
| No retries ever happen | Oracle's provider needs the numbers listed | The fix |
| Two rows after a retry | The tracker kept the first attempt's entity | The fix |
Conclusion
With retries on, put BeginTransactionAsync, your work and CommitAsync inside CreateExecutionStrategy().ExecuteAsync, state the lambda's return type, and clear the tracker first.
Additional Resources
- Connection Resiliency — execution strategies and transactions.
