Solving Race Conditions With EF Core Optimistic Locking
Learn how EF Core optimistic locking with RowVersion stops race conditions and lost updates, with simple examples, diagrams, and retry patterns.
Imagine two people sharing one bank passbook at home. Your mother reads the balance: 5000 rupees. A minute later your father also reads the same passbook: 5000 rupees. Your mother spends 2000 and writes 3000. Your father, still looking at his old number, spends 1000 and writes 4000. Now the passbook says 4000, but it should say 2000. Your mother's spending just vanished.
That silent mistake is a race condition. Two people raced to update the same thing, and one update got lost. In software we call this a lost update, and it happens all the time when many users hit the same database row at once.
This article shows how EF Core fixes this with optimistic locking. By the end you will understand the problem clearly, know how to add a RowVersion column, and be able to handle the conflict in a calm, friendly way.
What exactly goes wrong
Let us say we have an online shop. A product has a Quantity field. Two warehouse workers open the same product page. Both see Quantity = 10. Both want to subtract some stock.
Here is the order of events when there is no protection.
Worker 1 subtracted 3 (10 to 7). Worker 2 subtracted 6 (10 to 4). The correct answer should be 10 - 3 - 6 = 1. But because Worker 2 was still holding the old value of 10, the final stored value is 4. Three units of stock disappeared on paper. That is a lost update.
This is not a rare bug. Any time two requests touch the same row close together, you can hit it. Busy carts, ticket booking, seat selection, wallet balances, inventory counts: all classic danger zones.
Two ways to lock
There are two main strategies to stop this. Let us compare them before we pick one.
| Approach | How it works | Good for | Cost |
|---|---|---|---|
| Optimistic locking | No lock taken. Check at save time if the row changed. | Most web apps, low to medium conflict | Cheap, but you must handle conflicts |
| Pessimistic locking | Lock the row so others must wait. | Short, very hot rows with frequent clashes | Can cause waiting and deadlocks |
Optimistic locking is the friendly default for web applications. It assumes conflicts are rare (it is "optimistic"), so it does not slow everyone down with locks. It only acts when an actual conflict is detected at the moment of saving.
Choosing a locking strategy
Steps
Conflicts rare?
Most web apps: yes
Optimistic
Use RowVersion check
Pessimistic
Lock the row, others wait
The rest of this article focuses on optimistic locking, because that is what EF Core makes easy and what most students will use first.
The core idea: a version stamp
Optimistic locking works with one simple trick. Each row gets a hidden version number. Every time the row is saved, the version changes.
When you read the row, you also read its version. When you save, EF Core says to the database: "Update this row, but ONLY if the version is still the same as when I read it." If someone else already changed the row, its version moved on, and your update matches zero rows. EF Core notices this and throws an exception so you can react.
The magic is in that WHERE version = v1 part. It is added by EF Core for you. You do not write it by hand.
Adding a RowVersion in EF Core
On SQL Server and PostgreSQL, the easiest concurrency token is a RowVersion. The database updates it automatically on every change, so you can never forget to bump it.
Here is an entity with a RowVersion property using the [Timestamp] attribute.
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public int Quantity { get; set; }
// EF Core uses this as the concurrency token.
// SQL Server updates it automatically on every save.
[Timestamp]
public byte[]? RowVersion { get; set; }
}If you prefer the Fluent API instead of attributes, configure it inside OnModelCreating. The two ways do the same thing.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.Property(p => p.RowVersion)
.IsRowVersion();
}IsRowVersion() is the Fluent API equal of the [Timestamp] attribute. Pick whichever style your team likes. After this change, create a migration and update the database so the new column exists.
// In the Package Manager Console or terminal:
// dotnet ef migrations add AddRowVersionToProduct
// dotnet ef database updateNow SQL Server adds a special rowversion column. It is an 8-byte value that the database bumps up every single time the row is touched. You never set it yourself.
What the SQL actually looks like
It helps to see what EF Core sends to the database. When you save a changed product, EF Core does not just say "set quantity to 7". It also checks the version in the WHERE clause.
UPDATE Products
SET Quantity = 7
WHERE Id = 42 AND RowVersion = 0x00000000000007D1;If another user already saved that row, its RowVersion is no longer 0x...07D1. So this statement updates zero rows. EF Core counts the affected rows. Zero means trouble, and it throws DbUpdateConcurrencyException.
Let us look at the same two-worker story again, but now with the version check switched on.
This is the whole point. The bug did not disappear by magic. Instead, the silent data loss turned into a clear, catchable error. Now your code can decide what to do, instead of quietly losing money or stock.
Catching the conflict
When the conflict happens, EF Core throws DbUpdateConcurrencyException from SaveChanges or SaveChangesAsync. You wrap the save in a try/catch and decide how to respond.
public async Task<bool> ReduceStockAsync(int productId, int amount)
{
var product = await _db.Products.FindAsync(productId);
if (product is null)
{
return false;
}
product.Quantity -= amount;
try
{
await _db.SaveChangesAsync();
return true;
}
catch (DbUpdateConcurrencyException)
{
// Someone else changed this product since we read it.
// We did NOT overwrite their change. Now we choose what to do.
return false;
}
}Catching the exception is only step one. The real question is: what do you do next? There are three common answers.
| Strategy | What you do | Best when |
|---|---|---|
| Tell the user | Show "Data changed, please reload" | A human is editing, like a form |
| Database wins | Throw away your change, keep their value | Their change matters more |
| Retry | Re-read fresh data and try again | Automated jobs, simple counters |
Strategy 1: Tell the user (client wins or asks)
For a screen where a person is editing a form, the kindest thing is to be honest. You tell them their copy is out of date and let them look at the new values.
A clean way to do this in a web API is to return HTTP 409 Conflict along with the current values from the database. The client can then show a "this was changed by someone else" message.
User-facing conflict flow
Steps
Save fails
DbUpdateConcurrencyException
Read fresh values
From the database entries
Return 409
Conflict + latest data
User reloads
Decides what to keep
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
// Current values that are actually in the database right now.
var dbValues = await entry.GetDatabaseValuesAsync();
if (dbValues is null)
{
// The row was deleted by someone else.
return Results.NotFound("This product no longer exists.");
}
var current = (Product)dbValues.ToObject();
return Results.Conflict(new
{
message = "This product was changed by someone else. Please review.",
latest = current
});
}Here ex.Entries gives you the entities that clashed. GetDatabaseValuesAsync fetches what the row looks like now. You hand that back so the user is not editing blind.
Strategy 2: Retry automatically
For automated work, like a background job that increments a counter, the simplest fix is to try again. Read the latest data, redo the change on top of it, and save once more. Keep a small retry limit so you never loop forever.
public async Task IncrementViewsAsync(int articleId)
{
const int maxAttempts = 3;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
var article = await _db.Articles.FindAsync(articleId);
if (article is null)
{
return;
}
article.Views += 1;
try
{
await _db.SaveChangesAsync();
return; // Success, we are done.
}
catch (DbUpdateConcurrencyException)
{
// Detach the stale entity so the next loop reads fresh data.
_db.Entry(article).State = EntityState.Detached;
if (attempt == maxAttempts)
{
throw; // Give up after a few honest tries.
}
// Otherwise loop and try again with fresh values.
}
}
}The key detail is detaching the stale entity, or re-reading, before the next attempt. If you keep the old version in memory, the next save will fail for the same reason. You must refresh so the new attempt carries the current version stamp.
Strategy 3: Merge (more advanced)
Sometimes you do not want database-wins or client-wins. You want to keep both changes when they touch different fields. For example, one user edited the product Name while another edited the Price. There is no real clash, just two separate edits.
In that case you read the database values, compare field by field, and build a merged result before saving again. This is more work and you only reach for it when a plain retry is not good enough. For most beginner projects, telling the user or retrying is plenty.
A note on ConcurrencyCheck
RowVersion is the best tool when your database supports it. But some databases, like SQLite, do not auto-update a version column. For those, you can mark a normal column as a concurrency token instead.
public class Account
{
public int Id { get; set; }
public decimal Balance { get; set; }
// This existing column is checked in the WHERE clause.
// Works where RowVersion is not available.
[ConcurrencyCheck]
public Guid Version { get; set; }
}With [ConcurrencyCheck], EF Core adds that column to the WHERE clause just like a RowVersion. The difference is that you are responsible for changing the value on each update (for example, assigning a new Guid). With RowVersion the database does that job for you, which is why RowVersion is preferred when it is available.
Common mistakes to avoid
A few traps catch students often. Keep these in mind.
- Forgetting to read the version. If you build an entity by hand and attach it without the original
RowVersion, EF Core has nothing to compare. Always load the row first, or carry the version along. - Catching the exception and ignoring it. An empty
catchblock hides the very bug you were trying to fix. Always do something: tell the user, retry, or merge. - Looping forever on retry. Always set a maximum number of attempts. Under heavy load an endless retry can hammer your database.
- Using a value that does not change. A concurrency token only helps if it actually changes on every update.
RowVersionhandles this for you; a manual token does not.
Quick recap
- A race condition happens when two users read the same row, both change it, and one change is silently lost. This is a lost update.
- Optimistic locking does not lock the row. It records a version when you read, and checks that version when you save.
- On SQL Server and PostgreSQL, use a
RowVersioncolumn via[Timestamp]orIsRowVersion(). The database updates it automatically. - EF Core adds the version to the
WHEREclause. If zero rows update, it throwsDbUpdateConcurrencyException. - Handle the conflict in one of three ways: tell the user (return HTTP 409), retry with fresh data, or merge changes.
- Always refresh before retrying, and always set a retry limit.
- Use
[ConcurrencyCheck]on a normal column for databases like SQLite that cannot auto-update a version.
References and further reading
- Handling Concurrency Conflicts - EF Core (Microsoft Learn)
- Tutorial: Handle concurrency - ASP.NET Core with EF Core (Microsoft Learn)
- Concurrency Management in Entity Framework Core (Learn EF Core)
- Solving Race Conditions With EF Core Optimistic Locking (Milan Jovanovic)
Related Posts
Working With Transactions in EF Core: A Beginner-Friendly Guide
Learn how transactions work in EF Core with simple examples, savepoints, TransactionScope, execution strategies, diagrams, and clear best practices.
Understanding Change Tracking for Better Performance in EF Core
Learn how EF Core change tracking works, the entity states it uses, and simple tricks like AsNoTracking to make your .NET apps faster.
Calling Views, Stored Procedures and Functions in EF Core
A friendly, beginner guide to calling database views, stored procedures, and functions in EF Core with FromSql, SqlQuery, ExecuteSql, and ToView.
How to Use Global Query Filters in EF Core (Beginner Guide)
Learn EF Core global query filters with simple examples for soft delete and multi-tenancy, plus the new named filters in EF Core 10.
EF Core Bulk Insert: Boost Performance with Entity Framework Extensions
Learn how EF Core bulk insert with Entity Framework Extensions saves data faster, using simple examples, diagrams, and clear performance comparisons.
How to Use EF Core Interceptors: A Beginner-Friendly Guide
Learn EF Core interceptors step by step. Add auditing, soft delete, logging, and timing to your DbContext with clean, reusable code and zero clutter.