Skip to main content
SEMastery
Data Accessintermediate

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.

12 min readUpdated February 28, 2026

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.

Two users reading the same row, then both writing, causing a lost update.

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.

ApproachHow it worksGood forCost
Optimistic lockingNo lock taken. Check at save time if the row changed.Most web apps, low to medium conflictCheap, but you must handle conflicts
Pessimistic lockingLock the row so others must wait.Short, very hot rows with frequent clashesCan 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

Conflicts rare?
Optimistic
Pessimistic

Steps

1

Conflicts rare?

Most web apps: yes

2

Optimistic

Use RowVersion check

3

Pessimistic

Lock the row, others wait

A quick way to decide which approach fits your case.

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.

How a version check turns a silent overwrite into a loud, catchable conflict.

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 update

Now 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.

With a RowVersion check, the second save fails loudly instead of overwriting silently.

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.

StrategyWhat you doBest when
Tell the userShow "Data changed, please reload"A human is editing, like a form
Database winsThrow away your change, keep their valueTheir change matters more
RetryRe-read fresh data and try againAutomated 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

Save fails
Read fresh values
Return 409
User reloads

Steps

1

Save fails

DbUpdateConcurrencyException

2

Read fresh values

From the database entries

3

Return 409

Conflict + latest data

4

User reloads

Decides what to keep

Turn a concurrency error into a clear message for a human.
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.

Retry loop: refresh data and try again, up to a small limit.

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 catch block 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. RowVersion handles 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 RowVersion column via [Timestamp] or IsRowVersion(). The database updates it automatically.
  • EF Core adds the version to the WHERE clause. If zero rows update, it throws DbUpdateConcurrencyException.
  • 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

Related Posts