Skip to content
Kumar Chandrachooda
.NET

Getting Transactions Right with EF Core

SaveChanges is already a transaction, until it isn't enough. A tour of explicit transactions, TransactionScope, execution strategies and the failure modes that only show up in production.

By Kumar Chandrachooda 10 Jul 2025 3 min read
Begin, do the work, commit: everything else is detail

Entity Framework Core makes the happy path so smooth that it is easy to forget transactions exist, right up until an order is charged but never created. This post collects the transaction patterns I reach for in real systems, and the sharp edges around each one.

New to the platform? Here is the hundred-second version before we go deep:

The freebie: SaveChanges

Every call to SaveChanges already runs inside a transaction. If any statement fails, the whole batch rolls back:

using var db = new ShopContext();

db.Orders.Add(order);
db.OrderLines.AddRange(lines);
db.AuditEntries.Add(audit);

// One implicit transaction around all three inserts.
await db.SaveChangesAsync();

If you can arrange your unit of work so that one SaveChanges covers it, you are done. No explicit transaction needed. Most code that begins a transaction around a single SaveChanges call is ceremony.

Explicit transactions: multiple SaveChanges, one outcome

The pattern earns its keep when a business operation needs several SaveChanges calls, such as when you need the database-generated ID from the first insert to build the second:

await using var tx = await db.Database.BeginTransactionAsync();
try
{
    db.Orders.Add(order);
    await db.SaveChangesAsync();          // order.Id is now populated

    db.Shipments.Add(new Shipment
    {
        OrderId = order.Id,
        Carrier = PickCarrier(order)
    });
    await db.SaveChangesAsync();

    await tx.CommitAsync();
}
catch
{
    await tx.RollbackAsync();
    throw;
}

The await using on the transaction matters: if commit is never reached, disposal rolls back automatically. The explicit RollbackAsync in the catch block makes the intent readable, but the using is the actual safety net.

TransactionScope: spanning more than one context

When an operation spans two DbContext instances (or EF plus raw SqlCommand work), TransactionScope lets everything enlist in the same ambient transaction:

var options = new TransactionOptions
{
    IsolationLevel = IsolationLevel.ReadCommitted,
    Timeout = TimeSpan.FromSeconds(30)
};

using var scope = new TransactionScope(
    TransactionScopeOption.Required,
    options,
    TransactionScopeAsyncFlowOption.Enabled);

await ordersContext.SaveChangesAsync();
await inventoryContext.SaveChangesAsync();

scope.Complete();   // forget this line and everything rolls back

Two rules I never break with TransactionScope:

  • Always pass TransactionScopeAsyncFlowOption.Enabled. Without it, the ambient transaction does not flow across await and you get the dreaded “transaction has aborted” at runtime.
  • Keep the scope short. An ambient transaction holds locks for its entire lifetime; no HTTP calls, no message publishing inside the scope.

If the two contexts open connections to different databases, the scope escalates to a distributed transaction. On SQL Server that means MSDTC; on Azure SQL it simply throws. Across service boundaries, reach for the outbox pattern instead of bigger transactions.

Retries change everything

Azure SQL connections drop. The standard fix is the retrying execution strategy:

builder.Services.AddDbContext<ShopContext>(options =>
    options.UseSqlServer(connectionString,
        sql => sql.EnableRetryOnFailure(maxRetryCount: 5)));

But a retry strategy cannot blindly re-run a hand-rolled transaction; it has no idea which parts already executed. EF refuses to guess and throws InvalidOperationException. The fix is to hand the strategy the whole unit of work so it can replay it from the top:

var strategy = db.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(async () =>
{
    await using var tx = await db.Database.BeginTransactionAsync();

    db.Orders.Add(order);
    await db.SaveChangesAsync();

    db.Shipments.Add(shipment);
    await db.SaveChangesAsync();

    await tx.CommitAsync();
});

The delegate must be idempotent from a clean start: it may run more than once, but never half-committed, because the transaction guarantees all-or-nothing per attempt.

Verifying what actually hits the database

Trust, but profile. The quickest way to see the transaction boundaries EF emits:

SELECT
    s.session_id,
    t.transaction_id,
    t.name            AS transaction_name,
    t.transaction_begin_time,
    s.program_name
FROM sys.dm_tran_active_transactions AS t
JOIN sys.dm_tran_session_transactions AS st ON st.transaction_id = t.transaction_id
JOIN sys.dm_exec_sessions AS s ON s.session_id = st.session_id
ORDER BY t.transaction_begin_time DESC;

Long-running rows in that view are the smoking gun for “we did I/O inside a transaction scope”.

Cheat sheet

Scenario Tool Watch out for
Single unit of work Plain SaveChanges Nothing; this is the good case
Multiple SaveChanges, one DB BeginTransactionAsync Dispose handles rollback
Multiple contexts / ADO mix TransactionScope Async flow option, escalation
Azure SQL + retries Execution strategy wrapping the transaction Delegate must be replayable
Cross-service consistency Outbox / saga, not a transaction Distributed transactions don't scale

Transactions are one of those topics where the API surface is small but the failure modes are not. Default to the implicit transaction, escalate deliberately, and let the execution strategy own retries. Your 3 a.m. self will thank you.

Filed under .NET