Skip to main content

How to Use TransactionScope with EF Core and MySQL

· 4 min read
Jagdish Kumawat
Founder @ Dewiride

TransactionScope wraps code in a transaction without passing anything around: everything on the connection inside it joins in, and it commits only if you call Complete(). It works with EF Core and MySql.Data, with three things to get right. If you don't need its ambient behaviour, BeginTransactionAsync is simpler.

Prerequisites​

  • A .NET C# project using EF Core. I used .NET 10, EF Core 10.0.14, MySql.Data 26.10.0 and MySQL 9.7.4.

Step 1: Write It​

An endpoint
g.MapPost("/scope-rr", async (InventoryDbContext db, TimeProvider clock, CancellationToken cancellationToken) =>
{
var strategy = db.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(
async ct =>
{
db.ChangeTracker.Clear();

using var scope = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.RepeatableRead },
TransactionScopeAsyncFlowOption.Enabled);

await db.Products.Where(p => p.Id == 1)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ReorderLevel, p => p.ReorderLevel + 1), ct);

scope.Complete();
},
cancellationToken);

return Results.Ok();
});

The endpoint in a macOS-style code window, with a TransactionScope using TransactionOptions and TransactionScopeAsyncFlowOption.Enabled, inside ExecuteAsync, and a call to scope.Complete.

Step 2: Get the Three Things Right​

Turn on async flow. TransactionScopeAsyncFlowOption.Enabled lets the ambient transaction follow await. The TransactionScope page lists it among the constructor options, and async code needs it.

Put it inside the execution strategy. Outside, you get the same error as for any other transaction:

API log
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.

Pass an isolation level. The default is Serializable, and the driver leaves that on the pooled connection.

Step 3: Complete It​

scope.Complete() is what commits. Here's MySQL's log for a scope that called it:

Output
+--------------+------------------------------------------------------------------------+
| command_type | statement |
+--------------+------------------------------------------------------------------------+
| Init DB | inventory |
| Query | SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ |
| Query | BEGIN |
| Query | UPDATE `products` AS `p` SET `ReorderLevel` = `p`.`ReorderLevel` + 1 W |
| Query | COMMIT |
+--------------+------------------------------------------------------------------------+

And for one that didn't, with the default options:

Output
+--------------+------------------------------------------------------------------------+
| command_type | statement |
+--------------+------------------------------------------------------------------------+
| Init DB | inventory |
| Query | SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE |
| Query | BEGIN |
| Query | INSERT INTO `categories` (`CreatedAtUtc`, `Name`) VALUES (timestamp('2 |
| Query | SELECT `Id` FROM `categories` WHERE ROW_COUNT() = 1 AND `Id` = LAST_IN |
| Init DB | inventory |
| Query | UPDATE `products` AS `p` SET `ReorderLevel` = `p`.`ReorderLevel` + 1 W |
| Query | ROLLBACK |
+--------------+------------------------------------------------------------------------+

The general query log for a TransactionScope without Complete, ending in ROLLBACK.

Leaving the block without Complete() rolls it back, and that's also what any exception does. In the log, the driver's own BEGIN comes from enlisting in the scope:

API log
Enlisted in an ambient transaction with isolation level 'Serializable'.

Step 4: Choose Between the Two​

BeginTransactionAsyncTransactionScope
How code joins itThe same DbContextAnything that opens a connection inside the block
How it commitsCommitAsyncComplete() and leaving the block
Isolation levelSent for each transactionSet on the session
AsyncBuilt inNeeds the flow option

I use BeginTransactionAsync unless something needs to join in without being told.

Common Mistakes​

MistakeWhat happensDo this instead
Leaving out the async flow optionThe transaction doesn't follow awaitPass TransactionScopeAsyncFlowOption.Enabled
Forgetting Complete()Nothing's saved, and there's no errorCall it last
Doing work after Complete()It's not safe to continue in the scopeCall it last

Troubleshooting​

SymptomLikely causeFix
does not support user-initiated transactionsThe scope is outside the strategyMove it inside
The level is SERIALIZABLE afterwardsThe default optionsThe fix
Nothing's savedNo Complete()Add it

Conclusion​

Inside the strategy, with the async flow option, an explicit level, and Complete() as the last line, TransactionScope does its job with MySQL. Use it when work needs to join a transaction it wasn't handed.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.