When working with databases, consistency matters more than almost anything else and often each use case touches more than one table.
A single failed operation should not leave your system in a partially updated state. This is exactly what transactions solve.
In EF Core, transactions are often “invisible” until you actually need control over them, which is why it's important to understand how they work.
What Are Database Transactions
A transaction is a sequence of database operations that are treated as a single unit of work.
It follows the classic ACID principles:
- Atomicity: Either everything succeeds or nothing does
- Consistency: The database maintains its integrity rules
- Isolation: Concurrent transactions don't affect each other
- Durability: Changes are permanent even in case of failures
At the database level, a transaction typically starts with BEGIN TRANSACTION and ends with COMMIT:
BEGIN TRANSACTION;
UPDATE Orders SET Status = 'Paid' WHERE Id = 1;
UPDATE Inventory SET Quantity = Quantity - 1 WHERE ProductId = 10;
COMMIT;
If anything fails before COMMIT, a ROLLBACK ensures no partial changes are stored.
Another important part of transactions are isolation levels.
An isolation level defines how isolated a transaction is from other concurrent transactions.
- Dirty reads: reading uncommitted data
- Non-repeatable reads: same query returns different results
- Phantom reads: new rows appear between queries
Common isolation levels:
- Read Uncommitted
- Read Committed
- Repeatable Read
- Serializable
By default EF Core uses the database default isolation level and you can override it when needed.
Implicit Transactions in EF Core
EF Core already wraps each call to SaveChanges or SaveChangesAsync in a transaction.
var order = new Order
{
Id = Guid.NewGuid(),
CreatedAt = DateTime.UtcNow
};
order.Items.Add(new OrderItem
{
ProductId = productId,
Quantity = quantity,
Price = price
});
dbContext.Orders.Add(order);
await dbContext.SaveChangesAsync();
Internally SaveChanges starts a transaction, executes SQL commands and commits. If anything fails, everything is rolled back.
Explicit Transactions in EF Core
When you need control over multiple operations, use explicit transactions.
await using var transaction = await dbContext.Database.BeginTransactionAsync();
try
{
var order = new Order { Id = Guid.NewGuid() };
dbContext.Orders.Add(order);
await dbContext.SaveChangesAsync();
await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE "Products" SET "Stock" = "Stock" - {0} WHERE "Id" = {1}",
quantity,
productId);
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
Everything between BeginTransactionAsync and CommitAsync runs in the same transaction.
It can be useful when multiple SaveChanges calls must be atomic.
Savepoints in EF Core
Savepoints allow partial rollbacks within a transaction. Instead of rolling back everything, you can roll back to a specific point.
await using var context = new AppDbContext();
await using var transaction = await context.Database.BeginTransactionAsync();
context.Orders.Add(new Order { Id = 1 });
await context.SaveChangesAsync();
await transaction.CreateSavepointAsync("AfterOrder");
context.Inventory.Add(new Inventory { ProductId = 10 });
await context.SaveChangesAsync();
await transaction.RollbackToSavepointAsync("AfterOrder");
await transaction.CommitAsync();
Not all providers support savepoints equally well. SQL Server and PostgreSQL do support them.
Cross-Context Transactions
In real-world applications, it’s common to split data access into multiple DbContext instances.
Without coordination, each DbContext uses its own transaction, which breaks atomicity.
Option 1: Shared connection (recommended)
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
await using var transaction = await connection.BeginTransactionAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(connection)
.Options;
await using var context1 = new AppDbContext(options);
await using var context2 = new AppDbContext(options);
context1.Database.UseTransaction(transaction);
context2.Database.UseTransaction(transaction);
context1.Orders.Add(new Order { Id = 1 });
await context1.SaveChangesAsync();
context2.Inventory.Add(new Inventory { ProductId = 10 });
await context2.SaveChangesAsync();
await transaction.CommitAsync();
Open one connection, start a transaction on it and share both with each DbContext via UseTransaction. All SaveChanges calls then run in the same local transaction.
Option 2: TransactionScope
using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
using var context1 = new AppDbContext();
using var context2 = new AppDbContext();
context1.Orders.Add(new Order());
await context1.SaveChangesAsync();
context2.Inventory.Add(new Inventory());
await context2.SaveChangesAsync();
scope.Complete();
TransactionScope creates an ambient transaction where each DbContext enlists via its DbConnection. Depending on configuration, it may stay local or escalate to a distributed transaction.
Conclusion
Transactions ensure multi-step operations remain consistent and reliable.
EF Core handles most cases automatically via SaveChanges. For complex flows, use explicit transactions or shared connection strategies.
Getting this right early prevents subtle and hard-to-debug data inconsistencies.
If you want to check out examples I created, you can find the source code here:
Source CodeI hope you enjoyed it, subscribe and get a notification when a new blog is up!
