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:
Principle | What It Guarantees |
---|---|
Atomicity | All statements succeed or none do |
Consistency | DB rules (constraints, triggers) are never violated |
Isolation | Concurrent transactions don’t step on each other’s toes |
Durability | Committed 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 DbContext
s, 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 blanketException
. - 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:
- Escrow pattern: First save to local DB, then publish an outbox message outside the ambient transaction.
- 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
Generally no—unless you require snapshot isolation for reports.
Not safely. Transactions should live no longer than the request that opened them.
Whatever your database’s default is (usually Read Committed). Override via BeginTransactionAsync(IsolationLevel.Serializable)
when needed.
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!