Working With Transactions in EF Core: A Beginner-Friendly Guide
Learn how transactions work in EF Core with simple examples, savepoints, TransactionScope, execution strategies, diagrams, and clear best practices.
Think about sending money to a friend using a payments app like UPI. Money leaves your account and arrives in your friend's account. These are two separate steps. But in your mind they are really one action: a transfer.
Now imagine the app crashes right after taking money from you, but before adding it to your friend. Your 500 rupees just vanished into thin air. Nobody got it. That would be terrible.
A transaction is the rule that stops this. It says: either both steps happen, or neither step happens. There is no in-between. The money either fully moves or stays exactly where it was. This article shows how to use transactions in EF Core so your data never ends up half-finished.
What a transaction really promises
A transaction groups several database changes into one all-or-nothing unit. People describe this promise with four words, often called ACID.
| Letter | Word | Plain meaning |
|---|---|---|
| A | Atomicity | All steps happen, or none do. No half work. |
| C | Consistency | The database moves from one valid state to another valid state. |
| I | Isolation | One transaction does not see another's half-done work. |
| D | Durability | Once committed, the change survives a power cut or crash. |
For this guide, the most important letter is A for atomicity. That is the "all or nothing" rule from our money example. If anything goes wrong in the middle, the database undoes everything and pretends you never started.
The good news: EF Core already helps you
Here is something many beginners do not know. EF Core wraps every call to SaveChanges in a transaction for you. You do not have to ask.
So if you add a new order and three order items, then call SaveChanges once, all four rows are written together. If the third item breaks a database rule, EF Core rolls back the whole thing. You never end up with an order that has missing items.
// All of these are saved in ONE automatic transaction.
var order = new Order { CustomerId = 7 };
order.Items.Add(new OrderItem { ProductId = 1, Quantity = 2 });
order.Items.Add(new OrderItem { ProductId = 5, Quantity = 1 });
context.Orders.Add(order);
// One SaveChanges = one transaction. All rows, or none.
await context.SaveChangesAsync();So when do you need to do more work? Only when one business action needs more than one SaveChanges, or when you mix EF Core writes with raw SQL, or when you want to pick the isolation level yourself. Let us look at that case next.
Starting your own transaction
Sometimes one logical action takes two separate saves. Maybe you save the order first to get its generated id, then use that id somewhere else and save again. You want both saves to live or die together.
For this you start an explicit transaction with Database.BeginTransactionAsync.
using var transaction = await context.Database.BeginTransactionAsync();
try
{
var order = new Order { CustomerId = 7 };
context.Orders.Add(order);
await context.SaveChangesAsync(); // first save
var audit = new AuditLog { Message = $"Created order {order.Id}" };
context.AuditLogs.Add(audit);
await context.SaveChangesAsync(); // second save
// Nothing is final until we commit.
await transaction.CommitAsync();
}
catch
{
// Any error: undo BOTH saves.
await transaction.RollbackAsync();
throw;
}The key idea: even though we called SaveChangesAsync twice, nothing is permanent until CommitAsync runs. If the second save throws, we call RollbackAsync and the first save disappears too. The order is never left without its audit log.
Notice the using keyword. It makes sure the transaction is cleaned up even if you forget. If you never commit, the transaction is rolled back when it is disposed. That is a safe default.
Explicit transaction lifecycle
Steps
Begin
Open the transaction
Work
Do saves and SQL
Decide
All ok or not?
Commit
Make changes final
Rollback
Undo everything
Seeing it as a sequence
A picture helps. Here is the order of messages between your code, EF Core, and the database during an explicit transaction.
If anything between BeginTransaction and Commit fails, you send Rollback instead, and the database throws away both inserts.
Savepoints: undo just a part
Now for a neat trick. What if you want to undo only the last step instead of the whole transaction? That is what a savepoint does. Think of it like a checkpoint in a video game. You can die and respawn at the checkpoint instead of starting the whole level again.
Here is a very useful fact: when you have already started a transaction, EF Core automatically creates a savepoint before each SaveChanges. If that SaveChanges fails, EF Core rolls back to just before it, so your transaction is still alive and clean. You do not lose the earlier work.
You can also make your own savepoints by hand.
using var transaction = await context.Database.BeginTransactionAsync();
context.Users.Add(new User { Name = "Asha" });
await context.SaveChangesAsync();
// Mark a checkpoint we can return to.
await transaction.CreateSavepointAsync("AfterUser");
try
{
context.Orders.Add(new Order { CustomerId = 999 }); // maybe invalid
await context.SaveChangesAsync();
}
catch
{
// Undo only the order. The user above is still saved.
await transaction.RollbackToSavepointAsync("AfterUser");
}
await transaction.CommitAsync(); // commits the user (and order if it worked)A couple of honest warnings. Savepoints need database support, and they do not work on SQL Server when MARS (Multiple Active Result Sets) is turned on. If MARS is on, EF Core simply will not create the automatic savepoint. So keep MARS off if you rely on this behaviour.
Rolling back to a savepoint
Steps
Save user
User row written
Savepoint
Checkpoint created
Save order
Order attempt
Fail
Order is invalid
Roll back
Order undone, user kept
Choosing how strict to be: isolation levels
When many people use the database at the same time, you sometimes want to control how much one transaction can "peek" at another's unfinished work. This is the isolation level.
You pass it when you begin the transaction.
using var transaction = await context.Database
.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
// ... your work ...
await transaction.CommitAsync();Here is a friendly comparison of the common levels. Higher up means more relaxed and faster. Lower down means stricter and safer, but with more blocking.
| Isolation level | What it prevents | Trade-off |
|---|---|---|
| Read Committed | Reading another transaction's uncommitted data | Default in many databases, good balance |
| Repeatable Read | Rows changing if you read them twice | Safer, a bit more locking |
| Serializable | Almost all surprises, acts like a queue | Safest, slowest, most blocking |
For most apps, the default (usually Read Committed) is fine. Reach for Serializable only on small, high-risk operations like booking the last seat or moving money. A deeper look at these levels lives in the linked guide at the end.
TransactionScope: spanning more than one DbContext
What if a single action needs to touch two different DbContexts, maybe two different databases? A normal BeginTransaction lives on one context. For wider cases, .NET offers an ambient transaction tool called TransactionScope.
It works like an invisible umbrella. Any database work done while the umbrella is open joins the same transaction automatically.
using var scope = new TransactionScope(
TransactionScopeOption.Required,
TransactionScopeAsyncFlowOption.Enabled); // needed for async!
using (var context = new AppDbContext())
{
context.Users.Add(new User { Name = "Ravi" });
await context.SaveChangesAsync();
}
using (var context2 = new AppDbContext())
{
context2.Logs.Add(new Log { Text = "User created" });
await context2.SaveChangesAsync();
}
scope.Complete(); // umbrella closes; everything commits togetherTwo important points. First, you must pass TransactionScopeAsyncFlowOption.Enabled when you use async, or the umbrella will not follow your code across await calls. Second, EF Core only joins TransactionScope if the database provider supports System.Transactions. Some providers ignore it, so test before you trust it.
The retry trap you must know about
This is the part that surprises most developers, so read slowly.
Cloud databases sometimes drop a connection for a moment. To survive that, EF Core has connection resiliency. You turn it on with EnableRetryOnFailure, and EF Core will quietly retry a failed operation a few times.
options.UseSqlServer(connectionString, sql =>
sql.EnableRetryOnFailure());Here is the problem. When retries are on, EF Core wants to retry a whole unit of work. But your manual BeginTransaction defines its own unit that EF Core cannot safely replay by itself. So if you mix BeginTransaction with retries, EF Core throws an error telling you it cannot guarantee correctness.
The fix is to wrap your transaction code inside an execution strategy. You ask EF Core for the strategy, then hand it your whole transaction block. Now EF Core can replay the entire block if a connection blips.
var strategy = context.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
using var transaction = await context.Database.BeginTransactionAsync();
context.Orders.Add(new Order { CustomerId = 7 });
await context.SaveChangesAsync();
context.AuditLogs.Add(new AuditLog { Message = "Order made" });
await context.SaveChangesAsync();
await transaction.CommitAsync();
});The whole async block becomes one retriable unit. If a transient failure happens, EF Core runs the block again from the top, including beginning a fresh transaction. This is the official, recommended pattern, so make it a habit whenever you both enable retries and start transactions yourself.
Transaction inside an execution strategy
Steps
Create strategy
Ask EF Core
Run block
Pass your code
Begin tx
Fresh transaction
Save + commit
Do the work
Retry if blip
Replay whole block
Sharing one transaction with raw SQL
Sometimes you run a raw SQL command next to your EF Core writes, and you want them in the same transaction. As long as both use the same DbConnection, EF Core's transaction already covers the raw command too.
using var transaction = await context.Database.BeginTransactionAsync();
await context.Database.ExecuteSqlAsync(
$"UPDATE Inventory SET Stock = Stock - 1 WHERE ProductId = 5");
context.Orders.Add(new Order { CustomerId = 7 });
await context.SaveChangesAsync();
await transaction.CommitAsync();Both the raw UPDATE and the EF INSERT commit together. If the order save fails, the stock change is rolled back too. This is handy when part of your logic is easier to express in plain SQL.
Common mistakes and how to avoid them
A short checklist of traps beginners fall into.
- Wrapping a single
SaveChangesin a manual transaction. You do not need to. EF Core already does it. Extra code, no benefit. - Forgetting to commit. If you start a transaction and never call
CommitAsync, disposal rolls it back and your work silently disappears. Always commit on the happy path. - Holding a transaction open too long. A long transaction locks rows and blocks other users. Do your slow work (calling an API, reading a file) before you begin, not in the middle.
- Mixing
BeginTransactionwithEnableRetryOnFailuredirectly. Use the execution strategy pattern shown above instead. - Reusing one
DbContextacross threads inside a transaction. ADbContextis not thread-safe. Keep transaction work on a single flow.
Quick recap
- A transaction means all or nothing: every step succeeds together, or everything is undone.
- EF Core already wraps each
SaveChangescall in its own transaction, so single saves are safe for free. - Start your own transaction with
BeginTransactionAsynconly when one action needs several saves, mixes raw SQL, or needs a chosen isolation level. AlwaysCommitAsyncon success andRollbackAsyncon error. - Savepoints let you undo only part of a transaction. EF Core makes one automatically before each
SaveChanges, and you can add your own withCreateSavepointAsync. They do not work with SQL Server MARS. - Isolation levels control how strict the transaction is. The default is usually fine; use Serializable only for small, high-risk work.
- TransactionScope can cover multiple DbContexts. Remember
TransactionScopeAsyncFlowOption.Enabledfor async code. - If you enable retries with
EnableRetryOnFailure, wrap manual transactions inDatabase.CreateExecutionStrategy().ExecuteAsyncso EF Core can safely replay the whole block. - Keep transactions short, commit on the happy path, and never share a
DbContextacross threads.
References and further reading
- Transactions - EF Core (Microsoft Learn)
- Connection Resiliency - EF Core (Microsoft Learn)
- Implement resilient EF Core SQL connections (Microsoft Learn)
- Working With Transactions In EF Core (Milan Jovanovic)
- 3 Essential Techniques for Managing Transactions in EF Core (elmah.io)
Related Posts
Solving Race Conditions With EF Core Optimistic Locking
Learn how EF Core optimistic locking with RowVersion stops race conditions and lost updates, with simple examples, diagrams, and retry patterns.
Complete Guide to Transaction Isolation Levels in SQL
Learn SQL transaction isolation levels the easy way: dirty reads, non-repeatable reads, phantoms, snapshot, and serializable with simple diagrams and C# code.
How to Manage EF Core DbContext Lifetime: A Beginner's Guide
Learn how to manage EF Core DbContext lifetime safely. Understand scoped, transient, singleton, pooling, and DbContextFactory with simple examples and diagrams.
Understanding Change Tracking for Better Performance in EF Core
Learn how EF Core change tracking works, the entity states it uses, and simple tricks like AsNoTracking to make your .NET apps faster.
Calling Views, Stored Procedures and Functions in EF Core
A friendly, beginner guide to calling database views, stored procedures, and functions in EF Core with FromSql, SqlQuery, ExecuteSql, and ToView.
How to Use Global Query Filters in EF Core (Beginner Guide)
Learn EF Core global query filters with simple examples for soft delete and multi-tenancy, plus the new named filters in EF Core 10.