Skip to main content
SEMastery
Data Accessbeginner

Introduction to Locking and Concurrency Control in .NET 6

A beginner-friendly guide to locking and concurrency control in .NET 6 and EF Core, with a simple analogy, diagrams, code, and optimistic vs pessimistic locking.

12 min readUpdated April 13, 2026

Two clerks and one school register

Picture a small school office. There is one paper register that holds each student's marks.

Two clerks, Asha and Ravi, both want to update Rohan's total marks. Asha opens the register and reads 40. At the same moment Ravi reads 40 too. Asha adds 10 and writes 50. A minute later Ravi adds 5 to the 40 he remembers and writes 45.

Now the register says 45. Asha's +10 is gone forever. Nobody got an error. The number is just wrong.

This is called a lost update, and it happens in software all the time when two users touch the same database row together. Concurrency control is how we stop this. It is a set of rules that keeps data correct when many people read and write at once.

In this guide we will learn, in plain language, how .NET 6 and Entity Framework Core help you handle this. We will cover two big ideas: optimistic locking and pessimistic locking.

What is a race condition?

When two pieces of code run at the same time and the final result depends on who finishes first, we call it a race condition. The clerks above were in a race. The slower writer won, and the faster writer's work vanished.

Two users read the same value, then both write. The second write erases the first. This is a lost update.

The database did exactly what it was told. The problem is that nobody told it the two writes were related. Concurrency control is how we add that missing rule.

Two ways to solve it

There are two main styles. Both are useful. You pick one based on how often clashes happen.

StyleCore ideaBest whenCost
OptimisticHope for no clash, check at save timeClashes are rareCheap, may need a retry
PessimisticLock the row first, make others waitClashes are commonSafe, but others wait

Think of it like a library book.

  • Optimistic: Anyone can photocopy the book freely. When you return your edits, the librarian checks if the book changed since you took your copy. If it did, you redo your work.
  • Pessimistic: Only one person may hold the book. Everyone else waits in a queue until it comes back.

Choosing a concurrency strategy

Start
Rare clashes?
Optimistic
Pessimistic

Steps

1

Start

You share a row

2

Rare clashes?

How often do users collide?

3

Optimistic

Yes: use a version token

4

Pessimistic

No: lock the row first

A simple way to pick between the two styles.

Optimistic concurrency in EF Core

EF Core uses optimistic concurrency by default. It takes no extra locks. Instead, it arranges for your save to fail if the row changed since you read it. To turn this on for a row, you add a concurrency token.

The easiest token on SQL Server is a rowversion column. SQL Server changes this value automatically every time the row is updated. EF Core reads it when you load the entity, remembers it, and checks it again at save time.

Here is a simple entity with a RowVersion token.

public class Account
{
    public int Id { get; set; }
    public string Owner { get; set; } = string.Empty;
    public decimal Balance { get; set; }
 
    // SQL Server fills and updates this automatically.
    // [Timestamp] tells EF Core to treat it as a concurrency token.
    [Timestamp]
    public byte[]? RowVersion { get; set; }
}

The [Timestamp] attribute does two jobs at once. It maps the property to a SQL Server rowversion column, and it tells EF Core to use that column as the concurrency token.

If you prefer the fluent API instead of an attribute, you write it like this.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Account>()
        .Property(a => a.RowVersion)
        .IsRowVersion(); // same effect as [Timestamp]
}

What EF Core actually does at save time

When you call SaveChanges, EF Core does not just send an UPDATE by Id. It adds the old RowVersion to the WHERE clause. The generated SQL looks roughly like this.

// Conceptual SQL that EF Core sends:
// UPDATE Accounts
// SET    Balance = @newBalance
// WHERE  Id = @id AND RowVersion = @originalRowVersion;
//
// If 0 rows match, someone else already changed the row,
// so EF Core throws DbUpdateConcurrencyException.

If another user changed the row first, its RowVersion is now different. The WHERE matches zero rows. EF Core sees that zero rows were affected and throws a DbUpdateConcurrencyException. That exception is your signal that a clash happened.

Optimistic flow: read with a version, save with a version check. Zero matched rows means a conflict.

Handling the conflict

You must catch the exception and decide what to do. A common, safe pattern is reload and retry: get the fresh database values, reapply your change, and save again.

public async Task WithdrawAsync(int accountId, decimal amount)
{
    while (true)
    {
        var account = await _db.Accounts.FindAsync(accountId);
        if (account is null) return;
 
        account.Balance -= amount;
 
        try
        {
            await _db.SaveChangesAsync();
            return; // success, leave the loop
        }
        catch (DbUpdateConcurrencyException ex)
        {
            // Someone else changed the row first.
            // Reload the latest values, then loop and try again.
            foreach (var entry in ex.Entries)
            {
                await entry.ReloadAsync();
            }
        }
    }
}

There are three normal ways to resolve a conflict. Pick the one that fits your app.

StrategyWhat you doGood for
Database winsReload, drop your change"Last refresh matters" screens
Client winsKeep your values, force the saveTrusted single editor
Ask the userShow both versions, let them chooseForms with important edits

Resolving a concurrency conflict

Conflict
Reload
Decide
Save

Steps

1

Conflict

Save failed

2

Reload

Get fresh DB values

3

Decide

DB wins, client wins, or ask

4

Save

Retry SaveChanges

What to do after catching DbUpdateConcurrencyException.

Pessimistic concurrency in EF Core

Sometimes clashes are not rare. Imagine selling the last seat on a bus. Many users hit the same row at the same second. Retrying again and again is wasteful and can even fail forever. Here, pessimistic locking is better: lock the row first so only one user can touch it.

EF Core has no built-in pessimistic lock method. You add the lock yourself with a raw SQL query that uses a database lock hint, inside an explicit transaction. On SQL Server you use WITH (UPDLOCK, ROWLOCK). On PostgreSQL you use FOR UPDATE.

public async Task BookSeatAsync(int seatId)
{
    // A transaction is required. The lock lives only inside it.
    using var tx = await _db.Database.BeginTransactionAsync();
 
    // UPDLOCK + ROWLOCK locks this one row for us.
    // Any other transaction must WAIT here until we commit.
    var seat = await _db.Seats
        .FromSqlInterpolated(
            $"SELECT * FROM Seats WITH (UPDLOCK, ROWLOCK) WHERE Id = {seatId}")
        .FirstAsync();
 
    if (!seat.IsTaken)
    {
        seat.IsTaken = true;
        await _db.SaveChangesAsync();
    }
 
    await tx.CommitAsync(); // commit releases the lock
}

The lock lives only as long as the transaction. When you commit or roll back, the database frees the lock and the next waiting user proceeds. So you must always make sure the transaction ends, even when an error happens. A using block helps here.

Pessimistic flow: Ravi must wait at the lock until Asha commits her transaction.

A word on deadlocks

Pessimistic locks can cause a deadlock. This happens when two transactions lock rows in a different order and then each waits for the other forever. The database notices, picks a "victim", and kills one transaction with an error.

You lower this risk by following a few simple habits:

  • Always lock rows in the same order across your code.
  • Keep transactions short. Do not call slow web services while holding a lock.
  • Consider NOWAIT (fail fast) or SKIP LOCKED (skip busy rows) when they fit your case.

How this connects to transaction isolation

Locking does not work alone. It works together with the database's transaction isolation level, which controls how much one transaction can see of another's unfinished work. .NET and EF Core run on top of these levels.

Isolation levelStops dirty readsStops lost updatesNotes
Read CommittedYesNoThe common default
Repeatable ReadYesMostlyHolds more locks
SerializableYesYesSafest, slowest
SnapshotYesDetectsUses row versions

For most apps, the default plus an optimistic RowVersion token is enough. You reach for higher isolation or pessimistic locks only on the few rows that truly need it.

A quick mental model

Here is the whole idea on one page.

The big picture: detect clashes with a version (optimistic) or prevent them with a lock (pessimistic).

Both styles aim for the same goal: no silent lost updates. Optimistic detects the clash and lets you react. Pessimistic prevents the clash by making others wait.

A worked example: topping up a wallet

Let us tie it all together with a tiny, real story. A user has a mobile wallet. Two requests arrive almost together: one adds 100 rupees, the other adds 50. With no concurrency control, both read the same starting balance and one top-up is lost, just like Rohan's marks.

With an optimistic token, the steps are clear. The first request reads the wallet and its RowVersion. The second request reads the same balance and the same RowVersion. The first request saves, so the database bumps the RowVersion. When the second request tries to save, its old RowVersion no longer matches, EF Core throws DbUpdateConcurrencyException, and our retry loop reloads the new balance and adds 50 on top of the correct number. Both top-ups now land, and the final balance is right.

Notice the key point. The token did not stop the second user. It only made the stale save fail loudly, so our code could fix it. That is the whole spirit of optimistic concurrency: fail fast, reload, and try again on fresh data.

For a wallet, optimistic is usually perfect because two top-ups on the exact same account at the exact same millisecond are rare. But if you were selling one concert ticket to a thousand fans pressing "buy" together, the clash rate is huge, and a pessimistic lock or a queue would serve you better. Choosing the right tool is most of the skill.

Common beginner mistakes

  • Forgetting the token. Without a RowVersion or other concurrency token, EF Core does nothing special, and lost updates can happen.
  • Swallowing the exception. Catching DbUpdateConcurrencyException and ignoring it just hides the bug. Always reload and decide.
  • Holding a lock too long. A pessimistic lock that waits on a slow API call blocks every other user. Keep it tight.
  • Locking without a transaction. A lock hint outside a transaction gives you no real protection.
  • Retrying forever. An unbounded retry loop can spin if conflicts never stop. Cap the retries and surface a clear error.

Quick recap

  • A race condition lets two users overwrite each other, causing a silent lost update.
  • Concurrency control is the rule set that keeps data correct under many simultaneous writers.
  • Optimistic concurrency (the EF Core default) takes no locks and detects a clash at save time using a concurrency token like RowVersion.
  • A mismatch throws DbUpdateConcurrencyException; you reload and retry, or ask the user.
  • Pessimistic concurrency locks the row first using raw SQL hints (UPDLOCK, ROWLOCK, or FOR UPDATE) inside a transaction.
  • The lock is freed when the transaction commits or rolls back, so keep transactions short.
  • Locking pairs with the database isolation level; default plus a version token suits most apps.
  • Use optimistic for rare clashes and pessimistic for hot, contested rows.

References and further reading

Related Posts