EF Core Transactions Guide & Code Examples (2025)

managing transactions ef core

Ever deployed a new feature only to watch your data roll back like a badly‑parked shopping cart? If you’ve ever lost sleep over half‑saved orders or phantom invoices, this guide is for you. Today I will unravel EF Core’s transaction story, show you where (and why) things go wrong, and arm you with practical C# examples you can paste straight into production.

Why Transactions Matter

A transaction is an all‑or‑nothing pledge to your database: “commit everything or roll it all back.” Without that guarantee, concurrent users, background jobs, and flaky networks turn your data into Swiss cheese. Transactions enforce the ACID principles—Atomicity, Consistency, Isolation, Durability—so your app sleeps soundly even when the servers don’t.

Quick analogy: Think of a transaction as a promise ring between your code and the DB: either the ceremony completes, or no one changes their Facebook status.

Understanding ACID

Before diving into code, let’s firm up the bedrock. ACID is your data‑integrity checklist:

PrincipleWhat It Guarantees
AtomicityAll statements succeed or none do
ConsistencyDB rules (constraints, triggers) are never violated
IsolationConcurrent transactions don’t step on each other’s toes
DurabilityCommitted data survives crashes & power cuts

Transaction Options in EF Core

EF Core gives you several ways to wrap operations in a safety blanket. Pick the one that fits your scenario and performance budget.

Implicit Transactions via SaveChanges()

If you call context.SaveChanges() once at the end of a unit of work, EF Core automatically opens a transaction, batches all generated SQL, and commits. Quick, simple, but limited to a single DbContext and database connection.

await using var context = new AppDbContext();

var order = new Order { /* init */ };
context.Orders.Add(order);

// Implicit transaction under the hood
await context.SaveChangesAsync();

Tip: When you call SaveChanges() multiple times in the same request cycle, each call spins up its own transaction. That’s extra round‑trips and room for failure.

Explicit Transactions with Database.BeginTransaction()

For multi‑step workflows where you must run several SaveChanges() calls—or mix in raw SQL—control the transaction yourself:

await using var context = new AppDbContext();
await using var tx = await context.Database.BeginTransactionAsync();

try
{
    // Step 1
    context.Customers.Add(new Customer { Name = "Ada" });
    await context.SaveChangesAsync();

    // Step 2 – raw SQL insert
    await context.Database.ExecuteSqlRawAsync(@"INSERT INTO AuditLogs ...");

    await tx.CommitAsync();
}
catch
{
    await tx.RollbackAsync();
    throw; // bubble up – logging middleware handles it
}

Why choose this?

  • Multiple DbContext.SaveChanges() inside one atomic unit.
  • Mix read/write operations with raw SQL or stored procs.
  • Handle custom retry logic before commit.

Nested Transactions & Savepoints

SQL Server (and EF Core ≥ 5) lets you create savepoints to partially roll back:

await using var context = new AppDbContext();
await using var tx = await context.Database.BeginTransactionAsync();

try
{
    await context.Database.ExecuteSqlRawAsync("SAVE TRANSACTION beforeDiscount");

    // risky discount logic
    if (!discountIsValid)
        await context.Database.ExecuteSqlRawAsync("ROLLBACK TRANSACTION beforeDiscount");

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

Great for large ETL jobs where one broken record shouldn’t nuke the entire batch.

Ambient Transactions with TransactionScope

Need to span multiple DbContexts, or even cross‑service calls inside the same process? Wrap them in a TransactionScope:

using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);

await using var primary = new AppDbContext();
await using var logging = new LoggingDbContext();

primary.Users.Add(new User { /* … */ });
await primary.SaveChangesAsync();

logging.Logs.Add(new AuditLog { /* … */ });
await logging.SaveChangesAsync();

scope.Complete();

Caveat: Distributed transactions may kick in if the DB providers differ or the connection strings point to different servers, adding latency.

Lightweight Retry with ExecutionStrategy

Blips happen. Combine transactions with EF Core’s built‑in connection resiliency:

var strategy = context.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
    await using var tx = await context.Database.BeginTransactionAsync();
    // ...data ops
    await tx.CommitAsync();
});

If SQL Azure momentarily drops your connection, EF Core transparently retries the entire delegate—including the transaction.

Best Practices for Bulletproof Transactions

  • Keep transactions short. The longer they’re open, the more locks and the higher the deadlock risk.
  • Reuse the same DbContext within a transaction to stay on one connection.
  • Handle specific exceptions. Catch DbUpdateConcurrencyException for optimistic concurrency conflicts, not a blanket Exception.
  • Log every rollback, ideally with correlation IDs, so Ops can trace why.
  • Favor optimistic concurrency when conflict likelihood is low; pessimistic locks are expensive.
  • Test under load. Simulate 100+ concurrent checkouts to surface deadlocks before Friday night.

Advanced Scenarios

Distributed Transactions (MSDTC)

If part of your workflow writes to SQL Server and part to a message queue or another database, you may hit the dreaded “The partner transaction manager has disabled…” MSDTC error. Options:

  1. Escrow pattern: First save to local DB, then publish an outbox message outside the ambient transaction.
  2. Dual‑write with compensation: Commit locally, then attempt remote write; if remote fails, enqueue a compensating command.

Multiple Providers in One Unit of Work

EF Core can’t wrap PostgreSQL and SQL Server writes in a single local transaction. Use the Saga pattern or a distributed transaction coordinator—just be aware of the trade‑offs.

Real‑World Example: E‑Commerce Checkout Workflow

Let’s wire a real checkout endpoint with payments, stock reservation, and audit logging.

public async Task<IActionResult> Checkout(CheckoutRequest request)
{
    await using var ctx = new StoreDbContext(_options);
    var strategy = ctx.Database.CreateExecutionStrategy();

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

        var order = new Order { CustomerId = request.CustomerId };
        ctx.Orders.Add(order);
        await ctx.SaveChangesAsync();

        // Reserve stock
        foreach (var line in request.Items)
        {
            var product = await ctx.Products.FindAsync(line.ProductId);
            product.UnitsInStock -= line.Quantity;
        }
        await ctx.SaveChangesAsync();

        // Call external payment microservice
        var paymentOk = await _payments.ChargeAsync(request.PaymentDetails, order.Total);
        if (!paymentOk) throw new PaymentFailedException();

        // Audit log lives in another DbContext
        await using var audit = new AuditDbContext(_auditOptions);
        audit.Logs.Add(new AuditLog { OrderId = order.Id, Event = "Checkout" });
        await audit.SaveChangesAsync();

        await tx.CommitAsync();
        return Ok(order.Id);
    });
}

What happens if payment fails? The exception bubbles up, tx.RollbackAsync() fires inside the ExecuteAsync wrapper, stock levels return, and the order row disappears as if nothing happened.

Testing Transactions Effectively

  • Unit tests: Use InMemory provider without transactions to focus on business logic.
  • Integration tests: Spin up a containerized SQL Server/PostgreSQL instance; wrap each test in a transaction and roll it back in DisposeAsync() to reset state.
  • Chaos tests: Randomly kill the SQL container mid‑commit to prove your retry strategy works.

FAQ: Practical Questions on EF Core Transactions

Do I need a transaction for pure reads?

Generally no—unless you require snapshot isolation for reports.

Can I share a transaction between ASP.NET requests?

Not safely. Transactions should live no longer than the request that opened them.

What isolation level does EF Core use by default?

Whatever your database’s default is (usually Read Committed). Override via BeginTransactionAsync(IsolationLevel.Serializable) when needed.

Why do I get The connection does not support MultipleActiveResultSets?

MARS is disabled; ensure you aren’t streaming results while starting a new command inside the same transaction.

Conclusion: Lock in Your Data Integrity

Transactions aren’t glamorous, but neither is a 2 a.m. hot‑fix after corrupting customer data. Master the patterns above, keep your scopes tight, and you’ll prevent “partial save” nightmares before they surface.

Your turn: Which transaction pattern will you implement first—TransactionScope, savepoints, or a resilient execution strategy? Tell me in the comments and let’s compare war stories!

Leave a Reply

Your email address will not be published. Required fields are marked *