Articles › EF Core & SQL
Transactions and Concurrency in EF Core: Optimistic Locking, Isolation and Race Conditions
Keep data consistent under load: how SaveChanges uses transactions, explicit transactions, execution strategies, optimistic concurrency with RowVersion, isolation levels and safe counters.
Two users buy the last item in stock at the same moment. Two admins edit the same article and one silently overwrites the other. A transfer debits one account but crashes before crediting the other. These are consistency problems, and they appear only under real concurrency — which is why they slip through testing. This guide shows how EF Core and SQL Server help you prevent them.
Transactions: all or nothing
A transaction groups operations so they either all succeed or all fail (atomicity), and other users don't see half-finished work (isolation).
SaveChanges is already transactional
var order = new Order { CustomerId = 7 };
order.Items.Add(new OrderItem { ProductId = 3, Quantity = 2 });
db.Orders.Add(order);
product.Stock -= 2;
await db.SaveChangesAsync(); // INSERT order, INSERT items, UPDATE product — one transaction
If any statement fails, everything rolls back. For most use cases, one SaveChanges per business operation is all you need.
Explicit transactions
Use an explicit transaction when one operation spans several SaveChanges calls, raw SQL or bulk operations:
await using var tx = await db.Database.BeginTransactionAsync(ct);
var from = await db.Accounts.SingleAsync(a => a.Id == fromId, ct);
var to = await db.Accounts.SingleAsync(a => a.Id == toId, ct);
from.Balance -= amount;
to.Balance += amount;
await db.SaveChangesAsync(ct);
await db.Transfers.Where(t => t.Status == TransferStatus.Pending && t.AccountId == fromId)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TransferStatus.Done), ct);
await tx.CommitAsync(ct); // without Commit, disposing the transaction rolls back
Keep transactions short: never call external APIs, send emails or wait for user input inside one. Long transactions hold locks and block other requests.
Transactions with retry strategies
With EnableRetryOnFailure (common for cloud databases), wrap the whole unit in an execution strategy so a transient failure retries the entire transaction:
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var tx = await db.Database.BeginTransactionAsync(ct);
// ... work
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
});
Concurrency conflicts: the lost update problem
10:00:00 Admin A loads article #5 (Title = "Old")
10:00:05 Admin B loads article #5 (Title = "Old")
10:00:30 A saves Title = "Version A"
10:00:40 B saves Excerpt = "New excerpt" → also writes Title = "Old" back!
B's save silently overwrote A's change. This is the lost update problem.
Optimistic concurrency with RowVersion
Optimistic concurrency assumes conflicts are rare, doesn't lock anything, but detects conflicts at save time. In SQL Server, a rowversion column changes automatically on every update:
public class Article
{
public int Id { get; set; }
public string Title { get; set; } = "";
[Timestamp] // maps to SQL Server rowversion
public byte[] RowVersion { get; set; } = [];
}
EF Core now adds the version to the WHERE clause:
UPDATE Articles SET Title = @p0
WHERE Id = @p1 AND RowVersion = @p2;
If someone else changed the row, zero rows match and EF Core throws DbUpdateConcurrencyException:
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var databaseValues = await entry.GetDatabaseValuesAsync(ct);
if (databaseValues is null)
return NotFound("The article was deleted by another user.");
// Option 1: tell the client to reload and retry (most common for UIs)
return Conflict("This article was modified by someone else. Please reload.");
// Option 2: "client wins" — refresh the original values and save again
// entry.OriginalValues.SetValues(databaseValues);
// await db.SaveChangesAsync(ct);
}
Round-tripping the version through an API
For web APIs, send the version to the client and require it back:
public record ArticleDto(int Id, string Title, string Version); // Base64 rowversion
public record UpdateArticle(string Title, string Version);
var article = await db.Articles.FindAsync([id], ct);
db.Entry(article!).Property(a => a.RowVersion).OriginalValue = Convert.FromBase64String(cmd.Version);
article!.Title = cmd.Title;
await db.SaveChangesAsync(ct); // throws if the version is stale
This maps naturally to HTTP: the version can be an ETag, sent back in If-Match, and a conflict becomes 412 Precondition Failed or 409 Conflict.
Race conditions on counters and stock
Read-modify-write in application code is unsafe under concurrency:
// ❌ Two requests read Stock = 1, both subtract 1, both save → stock = 0, but two items sold
var product = await db.Products.FindAsync(id);
if (product.Stock >= qty) { product.Stock -= qty; await db.SaveChangesAsync(); }
Let the database do it atomically:
// ✅ Single atomic statement with a guard condition
var updated = await db.Products
.Where(p => p.Id == id && p.Stock >= qty)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Stock, p => p.Stock - qty), ct);
if (updated == 0)
return Conflict("Not enough stock.");
The same pattern works for view counters, likes and quotas. Alternatively, mark Stock as a concurrency token (.IsConcurrencyToken()) and retry on conflict.
Unique constraints beat "check then insert"
// ❌ race: two requests both see "not exists" and both insert
if (!await db.Users.AnyAsync(u => u.Email == email)) db.Users.Add(newUser);
A unique index makes the database enforce it; catch the violation and return a friendly error. Validation gives a nice message in the common case; the constraint guarantees correctness in the rare race.
Isolation levels
Isolation controls what concurrent transactions can see:
| Level | Prevents | Notes |
|---|---|---|
| Read Uncommitted | nothing | dirty reads — avoid |
| Read Committed | dirty reads | SQL Server default |
| Repeatable Read | non-repeatable reads | holds shared locks longer |
| Serializable | phantoms | strongest, most blocking |
| Snapshot / RCSI | readers don't block writers | uses row versioning in tempdb |
Many teams enable Read Committed Snapshot Isolation (RCSI) on SQL Server so reads never block writes:
ALTER DATABASE Portfolio SET READ_COMMITTED_SNAPSHOT ON;
Choose stronger levels only for specific operations that need them:
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
Pessimistic locking (rarely needed)
When conflicts are frequent and retries are expensive, you can lock rows explicitly with raw SQL hints (WITH (UPDLOCK, ROWLOCK)) inside a transaction. Use this sparingly — it reduces throughput and can cause deadlocks.
Checklist
- One
SaveChangesper business operation where possible - Explicit, short transactions for multi-step operations
- Execution strategy wrapping transactions when retries are enabled
-
RowVersionon entities edited by multiple users - Atomic
ExecuteUpdatefor counters and stock - Unique constraints for uniqueness rules
- Consider RCSI to reduce blocking
Summary
SaveChanges is already atomic; add explicit transactions only when a business operation needs several steps, and keep them short. Prevent lost updates with optimistic concurrency (RowVersion + DbUpdateConcurrencyException), make counters and stock changes atomic in SQL, rely on unique constraints instead of "check then insert", and choose isolation levels deliberately. These patterns keep your data correct when real traffic arrives.
FAQ
Optimistic or pessimistic concurrency?
Optimistic for most web apps — conflicts are rare and nothing is locked. Pessimistic only for hot rows with frequent conflicts.
Does EF Core use transactions for reads?
No explicit transaction is needed for queries; each query runs under the database's default isolation.
Can I use RowVersion with SQLite or PostgreSQL?
They don't have SQL Server's rowversion, but you can use a concurrency token property you update yourself (or PostgreSQL's xmin system column via the Npgsql provider).