Skip to main content
SEMastery
Data Accessintermediate

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.

12 min readUpdated January 24, 2026

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.

LetterWordPlain meaning
AAtomicityAll steps happen, or none do. No half work.
CConsistencyThe database moves from one valid state to another valid state.
IIsolationOne transaction does not see another's half-done work.
DDurabilityOnce 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.

A money transfer as one transaction: both writes happen, or both are undone.

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

Begin
Work
Decide
Commit
Rollback

Steps

1

Begin

Open the transaction

2

Work

Do saves and SQL

3

Decide

All ok or not?

4

Commit

Make changes final

5

Rollback

Undo everything

The path your code follows from begin to commit or rollback.

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.

Message flow for a manual transaction with two saves and a commit.

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

Save user
Savepoint
Save order
Fail
Roll back to savepoint

Steps

1

Save user

User row written

2

Savepoint

Checkpoint created

3

Save order

Order attempt

4

Fail

Order is invalid

5

Roll back

Order undone, user kept

Only the work after the savepoint is undone; earlier work survives.

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 levelWhat it preventsTrade-off
Read CommittedReading another transaction's uncommitted dataDefault in many databases, good balance
Repeatable ReadRows changing if you read them twiceSafer, a bit more locking
SerializableAlmost all surprises, acts like a queueSafest, 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 together

Two 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.

One TransactionScope umbrella covering two DbContexts.

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

Create strategy
Run block
Begin tx
Save + commit
Retry if blip

Steps

1

Create strategy

Ask EF Core

2

Run block

Pass your code

3

Begin tx

Fresh transaction

4

Save + commit

Do the work

5

Retry if blip

Replay whole block

The strategy can replay the entire block on a transient failure.

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 SaveChanges in 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 BeginTransaction with EnableRetryOnFailure directly. Use the execution strategy pattern shown above instead.
  • Reusing one DbContext across threads inside a transaction. A DbContext is 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 SaveChanges call in its own transaction, so single saves are safe for free.
  • Start your own transaction with BeginTransactionAsync only when one action needs several saves, mixes raw SQL, or needs a chosen isolation level. Always CommitAsync on success and RollbackAsync on error.
  • Savepoints let you undo only part of a transaction. EF Core makes one automatically before each SaveChanges, and you can add your own with CreateSavepointAsync. 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.Enabled for async code.
  • If you enable retries with EnableRetryOnFailure, wrap manual transactions in Database.CreateExecutionStrategy().ExecuteAsync so EF Core can safely replay the whole block.
  • Keep transactions short, commit on the happy path, and never share a DbContext across threads.

References and further reading

Related Posts