EF Core connection resiliency is the built-in retry logic you enable with EnableRetryOnFailure in the provider options, which installs an execution strategy that retries transient failures with exponential backoff.
Short network interruptions, failovers, and throttling are temporary, so a retry usually succeeds.
Explicit transactions are the exception: wrap them in CreateExecutionStrategy so a retry cannot replay half the work.
A database operation can fail even when the query and data are valid. EF Core execution strategies handle the retry loop when you define the correct transactional boundary.
Transient Failures Are Normal
In cloud environments, database connections fail. Load balancers rotate. SQL Azure throttles requests. Network hiccups happen. These are transient failures - they succeed if you retry.
Without retry logic, a brief network blip causes 500 errors for your users. EF Core's execution strategy solves this by automatically retrying failed operations.
Enabling Retry Logic
SQL Server / Azure SQL
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
});
});
PostgreSQL (Npgsql)
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorCodesToAdd: null);
});
});
Both use exponential backoff with jitter by default.
What Gets Retried?
EF Core retries these operations:
SaveChangesAsync()ToListAsync(),FirstOrDefaultAsync(), etc.- Any LINQ query execution
It retries only transient SQL errors. Constraint violations, syntax errors, and other non-transient failures are not retried.
SQL Server Transient Error Numbers
The default transient error list for SQL Server includes, among others:
- -2: Timeout
- 20: Instance error
- 64: Connection error
- 233: Connection closed
- 10053: Transport error
- 10054: Connection reset
- 10060: Connection timeout
- 40143: Throttled (Azure SQL)
- 40197: Service error (Azure SQL)
- 40501: Service busy (Azure SQL)
- 40613: Database unavailable (Azure SQL)
- 49918: Not enough resources (Azure SQL)
- 49919: Too many requests (Azure SQL)
- 49920: Too many requests (Azure SQL)
You can add custom error numbers:
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: [4060, 18401]);
The Transaction Problem
Here's the critical pitfall. Retries don't work with manual transactions:
// ❌ This throws InvalidOperationException with retry enabled
using var transaction = await _db.Database.BeginTransactionAsync();
order.Status = OrderStatus.Confirmed;
await _db.SaveChangesAsync();
payment.Status = PaymentStatus.Captured;
await _db.SaveChangesAsync();
await transaction.CommitAsync();
Why? If the first SaveChangesAsync succeeds but the second fails, EF Core can't retry the second without replaying the first. The retry strategy doesn't know about your transaction boundaries.
The Fix: CreateExecutionStrategy
var strategy = _db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
using var transaction = await _db.Database.BeginTransactionAsync();
order.Status = OrderStatus.Confirmed;
await _db.SaveChangesAsync();
payment.Status = PaymentStatus.Captured;
await _db.SaveChangesAsync();
await transaction.CommitAsync();
});
ExecuteAsync wraps the entire operation - including the transaction - as a single retriable unit. If any step fails with a transient error, the whole block is retried from the beginning.
Custom Execution Strategy
For fine-grained control, create a custom strategy:
public class CustomRetryStrategy : SqlServerRetryingExecutionStrategy
{
private readonly ILogger<CustomRetryStrategy> _logger;
public CustomRetryStrategy(
ExecutionStrategyDependencies dependencies,
int maxRetryCount,
TimeSpan maxRetryDelay,
ILogger<CustomRetryStrategy> logger)
: base(dependencies, maxRetryCount, maxRetryDelay, null)
{
_logger = logger;
}
protected override bool ShouldRetryOn(Exception exception)
{
var shouldRetry = base.ShouldRetryOn(exception);
if (shouldRetry)
{
_logger.LogWarning(exception,
"Transient database error. Retrying...");
}
return shouldRetry;
}
protected override TimeSpan? GetNextDelay(Exception lastException)
{
var delay = base.GetNextDelay(lastException);
if (delay.HasValue)
{
_logger.LogWarning(
"Retrying in {Delay}ms after error: {Message}",
delay.Value.TotalMilliseconds,
lastException.Message);
}
return delay;
}
}
Register it (note the AddDbContext overload that exposes the service provider, so we can resolve the logger):
builder.Services.AddDbContext<ApplicationDbContext>((sp, options) =>
{
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.ExecutionStrategy(dependencies =>
new CustomRetryStrategy(
dependencies,
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
sp.GetRequiredService<ILogger<CustomRetryStrategy>>()));
});
});
Retry With Polly
For more advanced policies, combine EF Core with Polly. With Polly v8, that means a resilience pipeline:
var pipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder()
.Handle<SqlException>(ex => ex.IsTransient)
.Handle<TimeoutException>(),
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
Delay = TimeSpan.FromSeconds(1),
OnRetry = args =>
{
logger.LogWarning(
"Retry {Attempt} after {Delay}ms: {Error}",
args.AttemptNumber,
args.RetryDelay.TotalMilliseconds,
args.Outcome.Exception?.Message);
return ValueTask.CompletedTask;
}
})
.Build();
await pipeline.ExecuteAsync(async ct =>
{
await _db.SaveChangesAsync(ct);
});
But in most cases, EF Core's built-in retry is sufficient - it already knows which provider errors are transient.
Use Polly when you need circuit breakers or more complex resilience patterns, and avoid stacking Polly retries on top of EnableRetryOnFailure (you'd multiply the attempts).
Idempotency Matters
Retries mean your operation might execute more than once. Make sure your operations are idempotent.
Here's the subtle failure mode: the INSERT commits on the server, but the connection drops before the acknowledgment reaches your app.
EF Core sees a transient error and retries.
With database-generated keys, you get a duplicate row; with a client-generated key, the retry fails on the primary key violation.
// ❌ Not idempotent - a retry can re-execute an insert that already committed
var order = new Order { Id = Guid.NewGuid(), Total = 100 };
_db.Orders.Add(order);
await _db.SaveChangesAsync();
// ✅ Idempotent - upsert pattern
var order = await _db.Orders.FindAsync(orderId);
if (order is null)
{
order = new Order { Id = orderId, Total = 100 };
_db.Orders.Add(order);
}
else
{
order.Total = 100;
}
await _db.SaveChangesAsync();
Or use the Inbox Pattern for message consumers.
Health Check Integration
Monitor connection health alongside retry:
builder.Services.AddHealthChecks()
.AddSqlServer(
connectionString,
name: "sql-server",
timeout: TimeSpan.FromSeconds(5),
tags: ["db", "ready"]);
Summary
Enable the provider's retry strategy for transient database failures.
When you open an explicit transaction, execute the entire transaction through CreateExecutionStrategy so a retry cannot replay only half the work.
Assume the final commit can be ambiguous and give externally visible operations an idempotency strategy.
Frequently Asked Questions
How do I enable retry logic in EF Core?
Call EnableRetryOnFailure in the provider options when configuring the DbContext, for example inside UseSqlServer or UseNpgsql. This installs a retrying execution strategy with exponential backoff for transient errors.
What errors does EnableRetryOnFailure retry?
Only errors the provider classifies as transient, such as timeouts, dropped connections, and Azure SQL throttling codes. Constraint violations, syntax errors, and other deterministic failures are never retried.
Why does BeginTransaction throw when retry on failure is enabled?
The execution strategy cannot safely retry part of a user-defined transaction. Wrap the whole transaction in Database.CreateExecutionStrategy().ExecuteAsync so the entire block retries as one unit.
Do I need Polly if EF Core has built-in retries?
Usually not for database calls. EF Core execution strategies understand provider-specific transient errors. Add Polly when you need capabilities EF Core lacks, like circuit breakers across your data layer.
Are retried EF Core operations safe to run twice?
Not automatically. A retry can re-execute an insert whose first attempt actually committed. Design operations to be idempotent, for example with client-generated keys and upsert logic.



