Save Big on Cyber Monday! Up to 40% Off
ends in   {{days}}
Days
{{timeFormat.hours}}
:
{{timeFormat.minutes}}
:
{{timeFormat.seconds}}

EF Core Transactions Guide: What They Are And How to Use Them

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!

What is a transaction in Entity Framework Core?

EF Core Transaction

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:

  • Implicit transactions, where operations are automatically wrapped during a SaveChanges() call.
  • Explicit transactions, where developers define clear boundaries using APIs like BeginTransaction() or TransactionScope.

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:

  • Atomicity ensures all operations succeed together or none takes effect.
  • Consistency enforces rules so the database remains valid.
  • Isolation prevents concurrent transactions from interfering.
  • Durability makes changes permanent after a commit.

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.

Types of EF Core transactions

In this section, we explore EF Core transactions in more detail, with examples.

EF Core Transaction Types

Automatic transactions

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}");
}

Why are they useful?

This model has the following benefits:

  • It simplifies code by handling transaction boundaries automatically.
  • It is ideal for CRUD operations where all changes are persisted together.

When are they not enough?

Implicit transactions are limited to a single SaveChanges() call and a single DbContext. They don't support the following:

  • Multi-step workflows across several SaveChanges() calls.
  • Transactions spanning multiple DbContexts or databases.
  • Partial rollbacks or fine-grained error handling.

For these scenarios, you'll need explicit transactions using BeginTransaction() or TransactionScope operations.

Explicit transactions using BeginTransaction()

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:

  • Ensure data consistency across multiple SaveChanges() calls.
  • Coordinate EF Core operations with raw SQL commands.
  • Apply custom isolation levels for performance or concurrency control.
Pro tip
All Devart dotConnect providers fully support BeginTransaction() and UseTransaction() scenarios. You can easily mix ORM operations with native SQL execution using a single open connection, ideal for performance-critical or hybrid systems.

Nested transactions and savepoints

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.

When to use:
  • Large transactions with multiple logical stages.
  • Workflows where partial success is acceptable.
  • Scenarios requiring error recovery without discarding all progress.
Benefits:
  • Greater flexibility for complex operations.
  • Prevention of complete progress loss when an error occurs late in a workflow.
Limitations:
  • Savepoints are not supported by all database providers.
  • Misuse can still lead to inconsistent states if not handled carefully.
Note
In SQL Server, savepoints are incompatible with Multiple Active Result Sets (MARS). If MARS is enabled, EF Core will skip creating savepoints, even if MARS isn't actively used.

TransactionScope and distributed transactions

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.

Example
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:

  • Workflows spanning multiple DbContexts or databases.
  • Coordinating operations across different microservices or bounded contexts.
  • Ensuring transactional consistency in distributed systems.
Pro tip
ADO.NET providers like dotConnect offer robust support for TransactionScope, enabling distributed transactions across Oracle, MySQL, PostgreSQL, and SQL Server with minimal configuration.

Advanced Entity Framework Core transaction techniques: cross-context and hybrid workflows

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.

Cross-context transactions in EF Core

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.

Hybrid transactions with ADO.NET

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.

Pro tip
All Devart dotConnect providers (Oracle, MySQL, PostgreSQL, etc.) support reusing ADO.NET transactions. This gives you low-level control with high-level ORM convenience, perfect for hybrid systems and multi-database scenarios.

Real-world scenarios for EF Core transactions

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.

Multi-entity updates

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.

Conditional writes with manual rollback

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.

ASP.NET Core Identity and transactions

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

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

Best practices for EF Core transactions

Handling transactions correctly in EF Core is critical to maintain data integrity and application performance. Follow these best practices to avoid common pitfalls.

Keep transactions short-lived

Long-running transactions increase the risk of deadlocks, lock contention, and performance bottlenecks. Here's how to fix it:

  • Load all necessary data before starting a transaction.
  • Perform only essential operations inside the transaction.
  • Commit or roll back as soon as possible.

Pick the narrowest isolation level you need

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

Avoid external API calls inside database transactions

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.

Combine Polly with EF Core strategies for transient fault handling

In cloud-based or distributed systems, transient failures like timeouts and deadlocks are common. Use Polly to wrap your transactional logic for resilience.

Example
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;
  }
});
Note
EF Core's built-in execution strategies (for example, EnableRetryOnFailure) do not work with explicit transactions. Manual retries are required.

Log SQL and timing for visibility

Enable detailed logging of transaction events to diagnose failures and performance issues:

  • Use ILogger or telemetry tools like Application Insights.
  • Log transaction start, commit, rollback, and duration.
Example
optionsBuilder
  .UseSqlServer(connectionString)
  .EnableSensitiveDataLogging()
  .LogTo(Console.WriteLine, LogLevel.Information);

Testing and debugging EF Core transactions

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.

Unit tests with in-memory and SQLite providers

Testing transaction behavior requires simulating database interactions accurately. EF Core offers two primary testing approaches: in-memory provider and lightweight SQLite provider.

In-memory 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 provider (in-memory mode)

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.

Debugging failed transactions in production

When transactions fail in production, fast diagnosis is critical to minimize downtime and data inconsistencies. Use the following techniques to surface issues quickly:

  • Enable EF Core logging: Configure EF Core to log SQL commands and transaction events. This helps you trace operations leading up to failures.
    optionsBuilder
      .UseSqlServer(connectionString)
      .EnableSensitiveDataLogging()
      .LogTo(Console.WriteLine, LogLevel.Information);
  • Use command interceptors: Command interceptors allow you to monitor and manipulate database commands during runtime. They are especially useful for detecting unexpected transaction patterns.
    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}");
        }
    }
  • Monitor deadlocks and locking issues: Use database profiling tools (like SQL Server Profiler, pg_stat_activity for PostgreSQL, or MySQL's Performance Schema) to identify deadlocks and long-running transactions.
  • Capture detailed exception data: Always log exceptions with stack traces, inner exceptions, and relevant transaction context. This makes root cause analysis faster and more precise.

dotConnect for EF Core: Multi-datasource flexibility with advanced transaction support

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:

  • Coordinate transactions across SQL Server, Oracle, MySQL, PostgreSQL, and SQLite.
  • Mix EF Core operations with raw SQL within the same transaction.
  • Enable distributed transactions across multiple DbContexts using TransactionScope.
  • Maintain high performance under heavy concurrency.
How dotConnect enhances EF Core transaction management

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.

Reliable transactionScope support

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.

Savepoints for partial rollbacks

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.

Advanced diagnostics and profiling

dotConnect includes tools to trace transaction boundaries, monitor performance, and detect anomalies in real time, critical for debugging and optimizing transaction-heavy applications.

Hybrid EF Core + ADO.NET transactions

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.

Conclusion

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.

FAQ

How to use transactions in Entity Framework?
In EF Core, you can use BeginTransaction() for grouping multiple operations or TransactionScope for cross-context workflows. Single SaveChanges() calls already run in a transaction by default.
Does EF Core automatically use transactions?
Yes. Each SaveChanges() call runs inside an implicit transaction, but it’s limited to a single DbContext and doesn't span multiple calls. Use explicit transactions for complex workflows.
How to rollback an EF Core transaction manually?

Use Database.BeginTransaction(), and call Rollback() on failure:

using var transaction = context.Database.BeginTransaction();
try
{
  context.SaveChanges();
  transaction.Commit();
}
catch
{
  transaction.Rollback();
  throw;
}
Can I use EF Core with TransactionScope in .NET Core?
Yes, on Windows with .NET 7+. Earlier versions and non-Windows platforms don't support distributed transactions with TransactionScope.
What's the difference between SaveChanges and SaveChangesAsync in transactions?
SaveChanges is synchronous; SaveChangesAsync supports non-blocking operations. Both work with transactions, but async flows require TransactionScopeAsyncFlowOption.Enabled.
Can I use dotConnect to manage transactions across multiple databases?
Yes. dotConnect supports advanced transaction scenarios, including distributed transactions across multiple databases. It integrates with TransactionScope and extends EF Core’s native capabilities to coordinate changes across separate data sources reliably.
Can dotConnect help in managing long-running transactions in EF Core?
Absolutely. dotConnect provides robust support for savepoints and partial rollbacks, making it ideal for long-running workflows. You can use savepoints to create checkpoints, so only parts of a transaction are rolled back if an error occurs.
How do I configure dotConnect for optimal transaction performance in EF Core?

To optimize transaction performance with dotConnect:

  • Use explicit transactions for multi-step workflows.
  • Configure isolation levels appropriate for your workload (for example, ReadCommitted for high throughput).
  • Enable dotConnect's diagnostic tools to monitor transaction health and fine-tune performance.
Does dotConnect support TransactionScope in EF Core?

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.

Connect to data effortlessly in .NET

Streamline your .NET projects with feature-rich ADO.NET providers