EF Core transactions, isolation levels and retries with EnableRetryOnFailure

EF Core Interview Q&A November 13, 2026 csharp

A single SaveChangesAsync is already a transaction.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ======================================================================
// Q: How do transactions, isolation levels and retries work in EF Core?
//
// - A single SaveChangesAsync is already a transaction. You only need an
//   explicit transaction when several SaveChanges or ExecuteUpdate calls must
//   happen together.
// - Isolation: the default is Read Committed (SQL Server, PostgreSQL).
//   Repeatable Read / Serializable prevent more anomalies but cause
//   serialization failures, so they need retries. On SQL Server consider
//   READ_COMMITTED_SNAPSHOT so reads and writes do not block each other.
// - Transient errors: EnableRetryOnFailure. With your own transaction, wrap the
//   whole unit of work in an execution strategy so a retry re-runs all of it.
// - Keep transactions short: never make an HTTP call (ERP, email) inside a
//   transaction. It holds locks and a connection while it waits (Q10, outbox).
// ======================================================================

builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseNpgsql(cs, npg => npg.EnableRetryOnFailure(maxRetryCount: 3)));

var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
    await using var tx = await db.Database.BeginTransactionAsync(
        IsolationLevel.Serializable, ct);
    // ... all the work goes in here
    await db.SaveChangesAsync(ct);
    await tx.CommitAsync(ct);
});