Skip to main content

Does EF Core's SaveChanges Use a Transaction?

· 5 min read
Jagdish Kumawat
Founder @ Dewiride

Yes. Each call to SaveChangesAsync runs in a transaction of its own, so if one row fails, none of them are saved. That's easy to prove with MySQL's own log, and it also shows you where the protection stops.

Prerequisites​

Step 1: Save Two Rows, One of Which Fails​

This endpoint adds two categories in one SaveChangesAsync. Cables already exists, and the table has a unique index on the name, so the second insert fails:

An endpoint
g.MapPost("/save-two", async (InventoryDbContext db, TimeProvider clock) =>
{
var now = clock.GetUtcNow().UtcDateTime;

db.Categories.Add(new Category { Name = "Stands", CreatedAtUtc = now });
db.Categories.Add(new Category { Name = "Cables", CreatedAtUtc = now }); // This name already exists.

await db.SaveChangesAsync();

return Results.Ok();
});

The endpoint in a macOS-style code window, adding categories named Stands and Cables and then calling SaveChangesAsync once.

Response
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"traceId": "00-6d2604dcc04fdf4289f6723ba39ec593-a5c2a348da3abffc-00"
}

The first insert was fine on its own. Was Stands saved?

Output (counting rows named Stands)
{"name":"Stands","count":0}

It wasn't.

Step 2: See It in MySQL's Log​

The general query log has what MySQL received:

Output
+--------------------------------------------------------------+
| statement |
+--------------------------------------------------------------+
| SET TRANSACTION ISOLATION LEVEL REPEATABLE READ |
| BEGIN |
| INSERT INTO `categories` (`CreatedAtUtc`, `Name`) VALUES (ti |
| SELECT `Id` FROM `categories` WHERE ROW_COUNT() = 1 AND `Id` |
| INSERT INTO `categories` (`CreatedAtUtc`, `Name`) VALUES (ti |
| ROLLBACK |
+--------------------------------------------------------------+

The general query log showing BEGIN, two INSERT statements and ROLLBACK for one call to SaveChangesAsync.

EF Core wrapped the call in BEGIN and ROLLBACK. The SET TRANSACTION ISOLATION LEVEL line is the driver's, and it sends that for every transaction.

When all the rows are fine, the same call ends in COMMIT:

Output (one new row)
+----------------------------------------------------------------------------+
| statement |
+----------------------------------------------------------------------------+
| SET TRANSACTION ISOLATION LEVEL REPEATABLE READ |
| BEGIN |
| INSERT INTO `categories` (`CreatedAtUtc`, `Name`) VALUES (timestamp('2026- |
| SELECT `Id` FROM `categories` WHERE ROW_COUNT() = 1 AND `Id` = LAST_INSERT |
| COMMIT |
+----------------------------------------------------------------------------+

The EF Core docs say the same: "By default, if the database provider supports transactions, all changes in a single call to SaveChanges are applied in a transaction. If any of the changes fail, then the transaction is rolled back and none of the changes are applied to the database."

Step 3: See What It's Protecting You From​

You can turn this behaviour off, for comparison. Don't leave it off:

An endpoint (excerpt)
db.Database.AutoTransactionBehavior = AutoTransactionBehavior.Never;
Output (counting rows named Stands, after the same failure)
{"name":"Stands","count":1}

Now Stands stays, though the request failed. That half-finished state is what the automatic transaction prevents.

Step 4: Know Where It Stops​

The transaction covers one call. These aren't protected:

CodeWhy it isn't covered
Two SaveChangesAsync callsEach has its own transaction, and the first may commit before the second fails
ExecuteUpdateAsync and then SaveChangesAsyncExecuteUpdateAsync runs immediately, in its own
A read, a check in C#, then a saveThe read isn't in the save's transaction, so it's a race

For those, start a transaction yourself.

Common Mistakes​

MistakeWhat happensDo this instead
Adding a transaction around one SaveChangesAsyncIt's already in oneLeave it out
Assuming a request is one transactionEach save is separateStart one when you need several writes together
Leaving AutoTransactionBehavior.Never on for speedA failure leaves partial dataLeave the default

Conclusion​

One SaveChangesAsync is one transaction, so it's all or nothing, and you don't need to add anything. Once a unit of work has more than one call in it, you need your own.

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.