Transactions
Know when SaveChanges already is a transaction, wrap multi-step work in an explicit one, and make it work with retrying execution strategies.
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () => {
await using var tx = await db.Database.BeginTransactionAsync(ct);
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
});Every SaveChanges is atomic. When it has more than one statement to send, EF Core begins a transaction, sends the batched commands and commits; if any statement fails it rolls back, throws, and the change tracker keeps the pending changes. When only one statement is needed EF skips the explicit transaction, because a single statement is already atomic. Database.AutoTransactionBehavior controls this: WhenNeeded (default), Always or Never.
When you need your own
Use BeginTransactionAsync when several operations must succeed or fail together: two SaveChanges calls, SaveChanges plus ExecuteUpdate or ExecuteSql, or EF plus Dapper on the same connection. Every command in that context joins it until CommitAsync; disposing it without committing rolls back. Inside a user transaction, SaveChanges creates a savepoint first, so a failed save can be undone without dooming the outer transaction; you can also call CreateSavepointAsync yourself.
Execution strategies and retries
EnableRetryOnFailure() on the SQL Server or Npgsql provider retries transient failures (dropped connections, failovers, deadlocks) up to 6 times by default, with exponential back-off capped at 30 seconds. It retries whole operations: one query or one SaveChanges. It cannot replay half a transaction, so as soon as a retrying strategy is configured, the first command inside a transaction you began yourself throws InvalidOperationException: "The configured execution strategy does not support user-initiated transactions". Wrap the whole unit in db.Database.CreateExecutionStrategy().ExecuteAsync(...).
That delegate must be safe to run twice. Do all reads inside it, call ChangeTracker.Clear() at its start so entities added by a failed attempt are not inserted again, and never call external services from it. If the connection drops during COMMIT you cannot know whether it committed; ExecuteInTransactionAsync takes a verifySucceeded callback that checks before retrying.
Isolation levels
| Level | Prevents | Use for |
|---|---|---|
| Read committed (default on SQL Server and PostgreSQL) | Dirty reads | Almost everything |
| Repeatable read | Rows you read changing under you | Reports that read the same rows twice |
| Serializable | Every anomaly, including phantoms and write skew | Invariants across rows: "no two bookings overlap", "invoice numbers have no gaps" |
Snapshot (SQL Server, needs ALLOW_SNAPSHOT_ISOLATION) | Readers blocking writers | Consistent multi-query reads |
Pass the level with BeginTransactionAsync(IsolationLevel.Serializable). PostgreSQL aborts conflicting serializable transactions with SQLSTATE 40001; Npgsql classifies it as transient, so the retrying strategy re-runs the delegate.
Production failure modes
- A transaction left open across an HTTP call to a payment provider holds locks and a pooled connection for seconds; under load the pool runs dry. Commit first, call out after, and use an outbox for the message.
- Transactions that lock the same rows in different orders deadlock; PostgreSQL aborts one with 40P01 and the retry costs a whole attempt. Take locks in one consistent order, for example by sorting order lines by SKU.
TransactionScopewithawaitneedsTransactionScopeAsyncFlowOption.Enabledor the scope is lost across awaits. Prefer the DbContext transaction APIs.
Example
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ShopDb>(o => o.UseNpgsql(
builder.Configuration.GetConnectionString("Shop"),
npgsql => npgsql.EnableRetryOnFailure()));
builder.Services.AddSingleton(TimeProvider.System);
var app = builder.Build();
app.MapPost("/orders", async Task<Results<Created<OrderCreated>, Conflict<string>>> (
PlaceOrder request, ShopDb db, TimeProvider clock, CancellationToken ct) =>
{
var strategy = db.Database.CreateExecutionStrategy();
// The whole unit runs again on a transient failure, so it starts from a clean tracker.
return await strategy.ExecuteAsync<Results<Created<OrderCreated>, Conflict<string>>>(async () =>
{
db.ChangeTracker.Clear();
await using var tx = await db.Database.BeginTransactionAsync(ct);
// Same SKU order in every transaction: two orders for [A, B] and [B, A] cannot deadlock.
var reservations = request.Lines
.GroupBy(l => l.Sku)
.Select(g => new PlaceOrderLine(g.Key, g.Sum(l => l.Quantity)))
.OrderBy(l => l.Sku, StringComparer.Ordinal);
foreach (var line in reservations)
{
var reserved = await db.Products
.Where(p => p.Sku == line.Sku && p.Stock >= line.Quantity)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Stock, p => p.Stock - line.Quantity), ct);
if (reserved == 0)
{
// Undo the reservations already made for earlier lines.
await tx.RollbackAsync(ct);
return TypedResults.Conflict($"Not enough stock for {line.Sku}.");
}
}
var order = new Order
{
CustomerId = request.CustomerId,
PlacedAt = clock.GetUtcNow(),
Lines = request.Lines.Select(l => new OrderLine { Sku = l.Sku, Quantity = l.Quantity }).ToList()
};
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return TypedResults.Created($"/orders/{order.Id}", new OrderCreated(order.Id));
});
});
app.Run();
public record PlaceOrderLine(string Sku, int Quantity);
public record PlaceOrder(int CustomerId, List<PlaceOrderLine> Lines);
public record OrderCreated(int Id);
public class Product
{
public int Id { get; set; }
public string Sku { get; set; } = "";
public int Stock { get; set; }
}
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public DateTimeOffset PlacedAt { get; set; }
public List<OrderLine> Lines { get; set; } = [];
}
public class OrderLine
{
public int Id { get; set; }
public int OrderId { get; set; }
public string Sku { get; set; } = "";
public int Quantity { get; set; }
}
public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
public DbSet<Order> Orders => Set<Order>();
}When to use it
- Reserve stock for every line and insert the order in one transaction, so a failure on line three releases the reservations for lines one and two.
- Issue gap-free invoice numbers per year under serializable isolation, relying on the retry strategy to re-run aborted attempts.
- Record a payment exactly once even when the connection drops during COMMIT, by verifying the row exists before retrying.
More examples
Serializable for an invariant across rows
Under read committed, two requests can both read the same maximum and both insert the next number; one then dies on the unique index with a 500. Under serializable, PostgreSQL detects the read/write dependency and aborts one transaction with SQLSTATE 40001. Npgsql marks that error as transient, so the execution strategy re-runs the delegate, which reads the new maximum and succeeds. Keep serializable transactions short: the abort rate grows with their length.
using System.Data;
using Microsoft.EntityFrameworkCore;
public class Invoice
{
public int Id { get; set; }
public int Year { get; set; }
public int Sequence { get; set; }
public int CustomerId { get; set; }
public decimal Amount { get; set; }
public string Number => $"INV-{Year}-{Sequence:D5}";
}
public class BillingDb(DbContextOptions<BillingDb> options) : DbContext(options)
{
public DbSet<Invoice> Invoices => Set<Invoice>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Invoice>().Ignore(i => i.Number);
// The last line of defence if two transactions ever pick the same number.
modelBuilder.Entity<Invoice>().HasIndex(i => new { i.Year, i.Sequence }).IsUnique();
}
}
public class InvoiceNumbering(BillingDb db, TimeProvider clock)
{
// Requires UseNpgsql(..., o => o.EnableRetryOnFailure()): 40001 is retried as transient.
public Task<Invoice> IssueAsync(int customerId, decimal amount, CancellationToken ct)
{
var strategy = db.Database.CreateExecutionStrategy();
return strategy.ExecuteAsync(async () =>
{
db.ChangeTracker.Clear();
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
var year = clock.GetUtcNow().Year;
var last = await db.Invoices.Where(i => i.Year == year).MaxAsync(i => (int?)i.Sequence, ct) ?? 0;
var invoice = new Invoice { Year = year, Sequence = last + 1, CustomerId = customerId, Amount = amount };
db.Invoices.Add(invoice);
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return invoice;
});
}
}Commit acknowledgement lost: verify before retrying
If the network fails while COMMIT is in flight, the database may have committed even though the client saw an exception. A plain retry would insert the payment a second time. <code>ExecuteInTransactionAsync</code> calls the <code>verifySucceeded</code> delegate in exactly that case, and stops retrying if it returns true. The client-generated <code>Guid.CreateVersion7()</code> key is what makes the check possible; with a database-generated identity you would have nothing to look for.
using Microsoft.EntityFrameworkCore;
public class PaymentRecord
{
public Guid Id { get; set; }
public int OrderId { get; set; }
public decimal Amount { get; set; }
public string ProviderReference { get; set; } = "";
}
public class PaymentsDb(DbContextOptions<PaymentsDb> options) : DbContext(options)
{
public DbSet<PaymentRecord> Payments => Set<PaymentRecord>();
}
public class PaymentRecorder(PaymentsDb db)
{
public async Task RecordAsync(int orderId, decimal amount, string providerReference, CancellationToken ct)
{
var payment = new PaymentRecord
{
Id = Guid.CreateVersion7(),
OrderId = orderId,
Amount = amount,
ProviderReference = providerReference
};
db.Payments.Add(payment);
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteInTransactionAsync(
// acceptAllChangesOnSuccess: false keeps the entity Added in case of a retry.
async token => await db.SaveChangesAsync(acceptAllChangesOnSuccess: false, token),
// Runs only if the commit itself failed: did the row make it anyway?
async token => await db.Payments.AsNoTracking().AnyAsync(p => p.Id == payment.Id, token),
ct);
db.ChangeTracker.AcceptAllChanges();
}
}Savepoints for a partially failing import
A savepoint marks a point inside the transaction; rolling back to it undoes only the work after it, and the transaction stays usable. On PostgreSQL that matters because any error otherwise leaves the transaction aborted until it is rolled back entirely. The valid batches commit together at the end, and a bad batch is skipped and logged. This version assumes no retrying strategy; with one configured, wrap the method body in the strategy as in the main example.
using Microsoft.EntityFrameworkCore;
public record PriceRow(string Sku, decimal Price);
public class Product
{
public int Id { get; set; }
public string Sku { get; set; } = "";
public decimal Price { get; set; }
}
public class CatalogDb(DbContextOptions<CatalogDb> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
}
public class PriceImport(CatalogDb db, ILogger<PriceImport> logger)
{
public async Task<int> ImportAsync(IReadOnlyList<PriceRow[]> batches, CancellationToken ct)
{
var applied = 0;
await using var tx = await db.Database.BeginTransactionAsync(ct);
for (var i = 0; i < batches.Count; i++)
{
await tx.CreateSavepointAsync($"batch_{i}", ct);
try
{
foreach (var row in batches[i])
{
var updated = await db.Products.Where(p => p.Sku == row.Sku)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, row.Price), ct);
if (updated == 0) throw new InvalidOperationException($"Unknown SKU {row.Sku}");
}
applied += batches[i].Length;
}
catch (InvalidOperationException ex)
{
await tx.RollbackToSavepointAsync($"batch_{i}", ct);
logger.LogWarning(ex, "Skipped price batch {Batch}", i);
}
}
await tx.CommitAsync(ct);
return applied;
}
}
Discussion