-
ALL Sources
-
Database
-
Cloud
Database Data
Providers
Entity Framework Core has become the go-to ORM for modern .NET developers, offering powerful abstractions to work with relational data. But behind its clean API lies a fragile mechanism that safeguards your data: EF Core transactions.
Too often, developers assume that this system will safeguard their data by default. But beneath every call to SaveChanges(), there’s a chance that it can fail. Without proper transaction management, workflows spanning multiple operations, contexts, or external systems can break, even from a minor constraint violation or network blip.
This article shows how you can take better control of the EF Core transaction model. You will see where implicit handling falls short, how to apply explicit transactions effectively, and which best practices prevent silent data corruption.
Let's dive in!
In Entity Framework Core (EF Core), a transaction is a unit of work that ensures database changes are atomic: either all succeed or none persists. This atomicity protects your data from partial operations during failures, keeping systems consistent even when exceptions or network issues occur.
EF Core supports two approaches to managing transactions:
At the heart of any transaction system are the ACID properties: Atomicity, Consistency, Isolation, and Durability. These principles guarantee that transactions behave predictably under failure conditions:
EF Core applies these principles automatically for simple operations, but more complex workflows often require explicit transaction control. This is especially true when multiple SaveChanges() calls, DbContexts, or external systems are involved.
Providers like dotConnect extend EF Core's transaction capabilities further, offering robust support for distributed transactions and savepoints across Oracle, MySQL, PostgreSQL, and other databases.
Now that we've covered the core principles behind transactions in EF Core, let's look at the types of EF Core transactions in detail.
In this section, we explore EF Core transactions in more detail, with examples.
EF Core automatically wraps each call to SaveChanges() or SaveChangesAsync() in a transaction. This is known as an implicit transaction, a convenient feature for simple workflows where all changes fit within a single unit of work.
EF Core transaction example
using var context = new AppDbContext();
// Track multiple entities
context.Add(new Order { CustomerId = 42, Total = 99.95m });
context.Add(new AuditLog { Message = "New order placed." });
try
{
// One implicit transaction under the hood:
int rows = await context.SaveChangesAsync();
Console.WriteLine($"{rows} rows written.");
}
catch (DbUpdateException ex)
{
// If anything fails (constraint, timeout, etc.), everything rolls back.
Console.WriteLine($"Transaction failed: {ex.Message}");
}
This model has the following benefits:
Implicit transactions are limited to a single SaveChanges() call and a single DbContext. They don't support the following:
For these scenarios, you'll need explicit transactions using BeginTransaction() or TransactionScope operations.
For greater control, EF Core provides the Database.BeginTransaction() API. This allows you to define explicit transaction boundaries, execute multiple operations within a single transaction, and decide when to commit or roll back.
Here's an updated example using IsolationLevel.Serializable, the strongest isolation level.
using var context = new AppDbContext();
await using var tx = await context.Database.BeginTransactionAsync(
IsolationLevel.Serializable); // Strongest isolation
try
{
context.Add(new Warehouse { Name = "Central EU Depot" });
await context.SaveChangesAsync(); // uses *current* transaction
// Raw SQL inside the same tx
await context.Database.ExecuteSqlRawAsync("""
UPDATE inventory SET quantity = quantity - 10 WHERE sku = 'ABC-123';
""");
await tx.CommitAsync(); // Persist all changes
}
catch (Exception ex)
{
await tx.RollbackAsync(); // Manual roll-back
Console.WriteLine($"Rolled back: {ex.Message}");
}
This pattern is ideal for multi-step workflows where each step depends on the success of the previous one. Here, using an explicit EF transaction gives you precise control over commits and rollbacks.
Use explicit transactions to:
EF Core supports savepoints, allowing developers to create nested transactions within an existing transaction. A savepoint acts as a checkpoint that you can roll back to without aborting the entire transaction. This feature is critical for long-running operations, where partial progress needs protection.
EF Core also creates automatic savepoints whenever SaveChanges() is called inside an existing transaction. If an error occurs during saving, EF Core rolls the transaction back to the savepoint, preserving changes. This enables developers to correct issues and retry saving without discarding all prior work.
Below is an example of managing savepoints manually.
using (var context = new AppDbContext())
{
using var transaction = context.Database.BeginTransaction();
try
{
// First operation
context.Customers.Add(new Customer { Name = "Alice" });
context.SaveChanges();
// Create a savepoint
transaction.CreateSavepoint("AfterCustomerInsert");
// Second operation
context.Orders.Add(new Order { CustomerId = 1, Total = 200 });
context.SaveChanges();
// Something goes wrong here
throw new Exception("Simulated failure");
transaction.Commit();
}
catch
{
// Roll back to the savepoint instead of the start
transaction.RollbackToSavepoint("AfterCustomerInsert");
// Continue or handle error
transaction.Commit();
}
}
In this scenario, if the second operation fails, the code only rolls back to the savepoint. The first operation remains intact.
The .NET TransactionScope API allows developers to create ambient transactions. This allows multiple operations, even across different DbContexts or databases, to participate in the same transactional context. With EF Core TransactionScope, you can coordinate complex workflows atomically and ensure all changes succeed or fail together.
using var scope = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions
{
IsolationLevel = IsolationLevel.ReadCommitted,
Timeout = TimeSpan.FromSeconds(30)
},
TransactionScopeAsyncFlowOption.Enabled); // allow async/await
try
{
// Context #1
await using (var orders = new AppDbContext())
{
orders.Add(new Order { CustomerId = 7, Total = 57.20m });
await orders.SaveChangesAsync();
}
// Context #2 (different lifetime, same ambient tx)
await using (var billing = new BillingDbContext())
{
billing.Add(new Invoice { OrderId = 123, Amount = 57.20m });
await billing.SaveChangesAsync();
}
scope.Complete(); // Commit both contexts atomically
}
catch (Exception ex)
{
// On dispose, TransactionScope will roll back if .Complete() was never called
Console.WriteLine($"Transaction rolled back: {ex.Message}");
}
This pattern is particularly useful in the following cases:
In complex systems, you often encounter workflows that span multiple DbContexts or even mix transactions in Entity Framework Core with raw ADO.NET operations. EF Core provides APIs to support these advanced transaction patterns when working with relational databases.
EF Core allows multiple DbContexts to participate in the same database transaction by sharing a DbConnection and enlisting each context using Database.UseTransaction(). Below is an example.
using var connection = new SqlConnection(connectionString);
var options = new DbContextOptionsBuilder<BloggingContext>()
.UseSqlServer(connection)
.Options;
using var context1 = new BloggingContext(options);
await using var transaction = await context1.Database.BeginTransactionAsync();
try
{
context1.Blogs.Add(new Blog { Url = "http://blogs.msdn.com/dotnet" });
await context1.SaveChangesAsync();
using (var context2 = new BloggingContext(options))
{
await context2.Database.UseTransactionAsync(transaction.GetDbTransaction());
var blogs = await context2.Blogs
.OrderBy(b => b.Url)
.ToListAsync();
context2.Blogs.Add(new Blog { Url = "http://dot.net" });
await context2.SaveChangesAsync();
}
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
This pattern is essential when different parts of a workflow require separate DbContext instances but must still commit or roll back atomically. In these scenarios, understanding how Entity Framework transactions behave across contexts is key to preventing subtle data issues.
In some scenarios, you might start a transaction using raw ADO.NET and then hand it off to EF Core to perform ORM operations within the same transactional context. EF Core supports this pattern through the Database.UseTransaction() API. Below is an example of how it works.
using var connection = new SqlConnection("your-connection-string");
await connection.OpenAsync();
// Create and begin ADO.NET transaction
using var transaction = await connection.BeginTransactionAsync();
// Pass it into EF Core context
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(connection)
.Options;
await using var context = new AppDbContext(options);
// Enlist EF Core in the existing transaction
context.Database.UseTransaction(transaction);
try
{
context.Add(new Product { Name = "EF Core Mug", Price = 15.00m });
await context.SaveChangesAsync();
// You can also execute raw ADO.NET SQL here
using var command = new SqlCommand("UPDATE stock SET quantity = 50 WHERE sku = 'MUG-001'", connection, (SqlTransaction)transaction);
await command.ExecuteNonQueryAsync();
await transaction.CommitAsync(); // Both EF and raw SQL changes are persisted
}
catch (Exception ex)
{
await transaction.RollbackAsync();
Console.WriteLine($"Error, rolled back: {ex.Message}");
}
This hybrid approach is ideal for legacy systems or performance-critical workflows where EF Core and ADO.NET need to coexist.
A transaction in Entity Framework Core allows developers to build workflows that maintain consistency even in complex business logic. Let’s explore three common scenarios where transaction management is critical.
In real-world systems, operations often involve multiple related entities. For example, when creating a new customer and their first order, both changes must be persisted together to avoid orphaned records or inconsistent states. Below is an example of how to wrap both operations in a single transaction.
using (var context = new AppDbContext())
{
using var transaction = context.Database.BeginTransaction();
try
{
var customer = new Customer { Name = "Emma Johnson" };
context.Customers.Add(customer);
context.SaveChanges();
var order = new Order { CustomerId = customer.Id, Total = 250 };
context.Orders.Add(order);
context.SaveChanges();
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
This approach ensures that whenever saving the order fails, the new customer isn’t left in the database without an associated order.
Sometimes transactions need to handle conditional logic where a failure partway through requires rolling back earlier changes. EF Core supports this pattern with exception handling and explicit rollbacks. The example below shows how you can insert a new product and update inventory, but roll back if stock levels drop below a threshold.
using (var context = new AppDbContext())
{
using var transaction = context.Database.BeginTransaction();
try
{
var product = new Product { Name = "Laptop", Price = 1200 };
context.Products.Add(product);
context.SaveChanges();
var inventory = context.Inventory.First(i => i.ProductId == product.Id);
inventory.StockLevel -= 10;
if (inventory.StockLevel < 0)
throw new InvalidOperationException("Insufficient stock");
context.SaveChanges();
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
This ensures the product isn't saved if the stock adjustment fails business validation.
Managing users in ASP.NET Core Identity often involves multiple database operations: creating a user, assigning roles, and adding claims. Wrapping these operations in a single transaction avoids inconsistent user states (for example, when a user account is created without roles).
using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
{
var user = new IdentityUser { UserName = "[email protected]", Email = "[email protected]" };
var result = await userManager.CreateAsync(user, "P@ssw0rd!");
if (!result.Succeeded)
throw new Exception("User creation failed");
await userManager.AddToRoleAsync(user, "Admin");
await userManager.AddClaimAsync(user, new Claim("Department", "IT"));
scope.Complete();
}
This pattern ensures the user is fully provisioned or not created at all.
Handling transactions correctly in EF Core is critical to maintain data integrity and application performance. Follow these best practices to avoid common pitfalls.
Long-running transactions increase the risk of deadlocks, lock contention, and performance bottlenecks. Here's how to fix it:
The default ReadCommitted isolation level is often enough for most workloads. For high-consistency scenarios, use Serializable or Snapshot, but be aware of increased lock contention and resource usage.
| Isolation level | Description | Risk |
|---|---|---|
| Read Uncommitted | Allows dirty reads | Dirty/phantom reads |
| Read Committed | Default; avoids dirty reads | Non-repeatable reads |
| Repeatable Read | Prevents non-repeatable reads | Phantom reads possible |
| Serializable | Serializes transactions fully | High lock contention |
| Snapshot | Uses row versioning for consistency | Extra storage overhead |
Calling external services (like APIs or message queues) while holding database locks increases transaction duration and risk of deadlocks. Perform such calls before or after the transaction when possible.
In cloud-based or distributed systems, transient failures like timeouts and deadlocks are common. Use Polly to wrap your transactional logic for resilience.
var policy = Policy
.Handle<SqlException>()
.WaitAndRetry(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
policy.Execute(() =>
{
using var transaction = context.Database.BeginTransaction();
try
{
context.SaveChanges();
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
});
Enable detailed logging of transaction events to diagnose failures and performance issues:
optionsBuilder .UseSqlServer(connectionString) .EnableSensitiveDataLogging() .LogTo(Console.WriteLine, LogLevel.Information);
Even the most carefully designed transactions can behave unpredictably in edge cases or under production load. To build confidence in your code and diagnose failures effectively, combine robust unit testing with proactive debugging strategies.
Testing transaction behavior requires simulating database interactions accurately. EF Core offers two primary testing approaches: in-memory provider and lightweight SQLite provider.
This provider allows fast, isolated tests without external dependencies. However, it has a critical limitation: it doesn’t enforce relational constraints or transaction semantics. Tests that pass with in-memory may fail against a real database.
SQLite's in-memory database supports real relational behaviors, including transactions and foreign key constraints. It's a closer approximation of production and ideal for verifying transactional logic.
The example below shows how to configure SQLite for transactional testing.
var connection = new SqliteConnection("Filename=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
using (var context = new AppDbContext(options))
{
context.Database.EnsureCreated();
using var transaction = context.Database.BeginTransaction();
// Test transactional behavior here
}
We recommend that you prefer SQLite for tests involving transactions or relational integrity. Reserve the in-memory provider for non-transactional unit tests.
When transactions fail in production, fast diagnosis is critical to minimize downtime and data inconsistencies. Use the following techniques to surface issues quickly:
optionsBuilder .UseSqlServer(connectionString) .EnableSensitiveDataLogging() .LogTo(Console.WriteLine, LogLevel.Information);
public class TransactionInterceptor : DbTransactionInterceptor
{
public override void Committed(DbTransaction transaction, TransactionEndEventData eventData)
{
Console.WriteLine("Transaction committed successfully.");
} public override void Failed(DbTransaction transaction, TransactionErrorEventData eventData)
{
Console.WriteLine($"Transaction failed: {eventData.Exception.Message}");
}
}
When your workflows span multiple databases, services, or DbContexts, EF Core's default transaction handling can hit its limits. For these complex scenarios, data providers in ADO.NET like dotConnect extend EF Core's capabilities.
With dotConnect, teams can:
While EF Core provides solid transaction support out of the box, complex workflows often demand more control and visibility. dotConnect extends EF Core's capabilities with the following enterprise-grade features.
dotConnect fully supports ambient transactions using TransactionScope, enabling developers to coordinate multi-database and multi-DbContext workflows atomically. This is essential for systems that span multiple services or data sources.
In long-running transactions, dotConnect allows you to set savepoints and roll back only to specific points without discarding all progress. This gives developers granular control over error recovery.
dotConnect includes tools to trace transaction boundaries, monitor performance, and detect anomalies in real time, critical for debugging and optimizing transaction-heavy applications.
Mix EF Core operations with raw SQL commands within the same transaction using UseTransaction(), fully supported across Oracle, MySQL, PostgreSQL, and SQL Server.
Download the free trial today to see how dotConnect elevates transaction management in your EF Core projects.
With dotConnect, you can manage transactions across a wide range of databases and cloud platforms:
Whether your application relies on traditional relational databases or modern cloud systems, dotConnect helps you simplify multi-datasource workflows with confidence.
Transactions are the backbone of reliable data management in EF Core. Whether you’re handling multi-entity updates, conditional workflows, or user provisioning with ASP.NET Core Identity, they ensure your operations remain atomic and your data consistent, even under failure.
By mastering implicit and explicit transaction types, applying best practices like minimizing transaction lifetimes and implementing retry logic, and testing your workflows rigorously, you can avoid common pitfalls such as partial updates, deadlocks, and data corruption.
For systems that demand more advanced capabilities, like distributed transactions, savepoints, and deeper diagnostics, dotConnect extends EF Core with enterprise-grade features that give you full control over transactional behavior.
Dive deeper into EF Core patterns and advanced transaction techniques in Devart's resource library.
Use Database.BeginTransaction(), and call Rollback() on failure:
using var transaction = context.Database.BeginTransaction();
try
{
context.SaveChanges();
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
To optimize transaction performance with dotConnect:
Yes. dotConnect offers full support for TransactionScope, even in scenarios where EF Core's native implementation has limitations. This includes better cross-platform compatibility and improved reliability for distributed transactions.