DEV Community

Cover image for Optimistic and Pessimistic Locking in .NET with SQL Server
Ravi Vishwakarma
Ravi Vishwakarma

Posted on

Optimistic and Pessimistic Locking in .NET with SQL Server

When multiple users access the same application at the same time, they may also try to read or update the same database record.

For example:

  • User A opens a product and changes its price.
  • User B opens the same product at the same time and also changes its price.
  • Both users click Save.

What happens?

Without a strategy to handle concurrent updates, one user's changes may silently overwrite the other's. This is where concurrency control and database locking strategies become important.

Two common approaches are:

  1. Optimistic locking
  2. Pessimistic locking

In this article, we'll learn both approaches using .NET and SQL Server, with beginner-friendly examples and commented code.


What Problem Are We Trying to Solve?

Imagine we have a simple Products table.

CREATE TABLE Products
(
    Id INT PRIMARY KEY,
    Name NVARCHAR(100),
    Price DECIMAL(10, 2)
);
Enter fullscreen mode Exit fullscreen mode

The table contains this record:

Id Name Price
1 Laptop 50000

Now imagine two users open this product at exactly the same time.

User A

Reads:

Laptop - ₹50,000
Enter fullscreen mode Exit fullscreen mode

User A changes the price to:

₹55,000
Enter fullscreen mode Exit fullscreen mode

User B

At the same time, User B also reads:

Laptop - ₹50,000
Enter fullscreen mode Exit fullscreen mode

User B changes the price to:

₹48,000
Enter fullscreen mode Exit fullscreen mode

If User A saves first and User B saves afterward, User B's update may overwrite User A's change.

This is often called a lost update problem.

We need a way to detect or prevent this conflict.


1. What Is Optimistic Locking?

Optimistic locking assumes that conflicts are rare.

The basic idea is:

"Let everyone read and work with the data. When someone tries to save, check whether the data has changed since they originally read it."

If the data has not changed, the update succeeds.

If the data has changed, the application detects a concurrency conflict.

How Does It Work?

A common approach is to add a rowversion column to the SQL Server table.

CREATE TABLE Products
(
    Id INT PRIMARY KEY,
    Name NVARCHAR(100),
    Price DECIMAL(10, 2),

    -- SQL Server automatically changes this value
    -- whenever the row is updated.
    RowVersion ROWVERSION NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

The RowVersion column acts like a version identifier for the row.

For example:

Product before update:

Id: 1
Price: 50000
RowVersion: 0x00000000000007D1
Enter fullscreen mode Exit fullscreen mode

When the product is updated, SQL Server changes the RowVersion.

Product after update:

Id: 1
Price: 55000
RowVersion: 0x00000000000007D2
Enter fullscreen mode Exit fullscreen mode

Now, when a user attempts to update the record, we can check whether the version they originally read is still the current version.


Optimistic Locking with ADO.NET

First, let's create a simple C# model.

public class Product
{
    public int Id { get; set; }

    public string Name { get; set; } = string.Empty;

    public decimal Price { get; set; }

    // This stores the row version returned by SQL Server.
    // It helps us detect whether another user changed the row.
    public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
Enter fullscreen mode Exit fullscreen mode

Step 1: Read the Product

using Microsoft.Data.SqlClient;

public async Task<Product?> GetProductAsync(int id)
{
    // Replace this with your actual connection string.
    var connectionString =
        "Server=.;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";

    await using var connection =
        new SqlConnection(connectionString);

    await connection.OpenAsync();

    var sql = @"
        SELECT Id, Name, Price, RowVersion
        FROM Products
        WHERE Id = @Id";

    await using var command =
        new SqlCommand(sql, connection);

    // Use parameters instead of string concatenation.
    command.Parameters.AddWithValue("@Id", id);

    await using var reader =
        await command.ExecuteReaderAsync();

    if (!await reader.ReadAsync())
    {
        return null;
    }

    return new Product
    {
        Id = reader.GetInt32(0),
        Name = reader.GetString(1),
        Price = reader.GetDecimal(2),

        // Save the row version that existed when we read the record.
        RowVersion = (byte[])reader["RowVersion"]
    };
}
Enter fullscreen mode Exit fullscreen mode

When the user loads the product, we also store its current RowVersion.

Later, when the user clicks Save, we use that version in the UPDATE statement.


Step 2: Update the Product Safely

public async Task<bool> UpdateProductAsync(Product product)
{
    var connectionString =
        "Server=.;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";

    await using var connection =
        new SqlConnection(connectionString);

    await connection.OpenAsync();

    var sql = @"
        UPDATE Products
        SET
            Name = @Name,
            Price = @Price
        WHERE
            Id = @Id

            -- Only update if the row version is still the same
            -- as when the user originally read the record.
            AND RowVersion = @RowVersion";

    await using var command =
        new SqlCommand(sql, connection);

    command.Parameters.AddWithValue("@Id", product.Id);
    command.Parameters.AddWithValue("@Name", product.Name);
    command.Parameters.AddWithValue("@Price", product.Price);
    command.Parameters.Add("@RowVersion", System.Data.SqlDbType.Timestamp)
        .Value = product.RowVersion;

    // ExecuteNonQueryAsync returns the number of affected rows.
    var affectedRows = await command.ExecuteNonQueryAsync();

    // If 1 row was updated, the operation succeeded.
    // If 0 rows were updated, someone may have changed
    // the record before us.
    return affectedRows == 1;
}
Enter fullscreen mode Exit fullscreen mode

Now imagine this sequence:

User A reads the product

Price: 50000
RowVersion: Version 1
Enter fullscreen mode Exit fullscreen mode

User B reads the product

Price: 50000
RowVersion: Version 1
Enter fullscreen mode Exit fullscreen mode

User A updates the product

The product becomes:

Price: 55000
RowVersion: Version 2
Enter fullscreen mode Exit fullscreen mode

User B tries to update

But User B still has:

RowVersion: Version 1
Enter fullscreen mode Exit fullscreen mode

The SQL query checks:

WHERE Id = @Id
AND RowVersion = @RowVersion
Enter fullscreen mode Exit fullscreen mode

Since the database now contains Version 2, no row matches the condition.

Therefore:

affectedRows = 0
Enter fullscreen mode Exit fullscreen mode

The application can now tell User B:

"This product was modified by another user. Please reload the latest data and try again."

That is optimistic locking.


Optimistic Locking with Entity Framework Core

Entity Framework Core makes optimistic concurrency easier.

First, create the model:

using System.ComponentModel.DataAnnotations;

public class Product
{
    public int Id { get; set; }

    public string Name { get; set; } = string.Empty;

    public decimal Price { get; set; }

    // [Timestamp] tells EF Core that this property
    // should be used for optimistic concurrency checking.
    [Timestamp]
    public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
Enter fullscreen mode Exit fullscreen mode

You can also configure it using Fluent API:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>()
        .Property(p => p.RowVersion)
        .IsRowVersion();
}
Enter fullscreen mode Exit fullscreen mode

When EF Core detects that another user changed the row before SaveChangesAsync(), it can throw a concurrency exception.

try
{
    // Get the product.
    var product = await _context.Products.FindAsync(1);

    if (product == null)
    {
        return;
    }

    // Change the price.
    product.Price = 55000;

    // EF Core generates an UPDATE statement that checks
    // the original concurrency value.
    await _context.SaveChangesAsync();

    Console.WriteLine("Product updated successfully.");
}
catch (DbUpdateConcurrencyException)
{
    // This happens when another user changed or deleted
    // the record before our update was completed.
    Console.WriteLine(
        "The product was modified by another user. " +
        "Reload the latest data and try again.");
}
Enter fullscreen mode Exit fullscreen mode

This is one reason rowversion is commonly used with EF Core applications.


Advantages of Optimistic Locking

Optimistic locking works well when conflicts are relatively uncommon.

Benefits

  • Users are not blocked while reading data.
  • Long-running locks are avoided.
  • It generally scales well for many read operations.
  • It is useful for web applications where users may keep a page open for several minutes.

Disadvantages

  • The application must handle concurrency conflicts.
  • A user may spend time editing data and then discover that another user changed it.
  • Users may need to reload and merge changes.

2. What Is Pessimistic Locking?

Pessimistic locking takes the opposite approach.

It assumes:

"A conflict might happen, so lock the data while it is being used."

In simple terms:

  • User A accesses a row.
  • The application places a lock on that row.
  • User B tries to access or modify the same row.
  • User B may have to wait until User A finishes and releases the lock.

The lock usually exists within a database transaction.


Pessimistic Locking Example with SQL Server

Suppose we want to reserve a product row for update.

We can use locking hints such as:

UPDLOCK
Enter fullscreen mode Exit fullscreen mode

and:

ROWLOCK
Enter fullscreen mode Exit fullscreen mode

For example:

SELECT *
FROM Products WITH (UPDLOCK, ROWLOCK)
WHERE Id = 1;
Enter fullscreen mode Exit fullscreen mode

Let's understand these hints.

UPDLOCK

This requests an update lock while reading the row.

The intention is:

"I am reading this row because I plan to update it."

ROWLOCK

This asks SQL Server to prefer row-level locking rather than locking a larger resource.

However, it is important to understand that SQL Server manages locking internally, and locking hints are requests rather than a guarantee of a particular physical locking behavior in every situation.


Pessimistic Locking with ADO.NET

Here is a simple example.

using Microsoft.Data.SqlClient;

public async Task UpdateProductWithLockAsync(
    int productId,
    decimal newPrice)
{
    var connectionString =
        "Server=.;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";

    await using var connection =
        new SqlConnection(connectionString);

    await connection.OpenAsync();

    // Start a database transaction.
    await using var transaction =
        (SqlTransaction)await connection.BeginTransactionAsync();

    try
    {
        var selectSql = @"
            SELECT Id, Name, Price
            FROM Products WITH (UPDLOCK, ROWLOCK)
            WHERE Id = @Id";

        await using var selectCommand =
            new SqlCommand(selectSql, connection, transaction);

        selectCommand.Parameters.AddWithValue("@Id", productId);

        await using var reader =
            await selectCommand.ExecuteReaderAsync();

        if (!await reader.ReadAsync())
        {
            throw new Exception("Product not found.");
        }

        // Read the current values while the transaction
        // is still holding the requested lock.
        var productName = reader.GetString(1);
        var currentPrice = reader.GetDecimal(2);

        Console.WriteLine(
            $"Current price of {productName}: {currentPrice}");

        // Close the reader before executing another command.
        await reader.CloseAsync();

        var updateSql = @"
            UPDATE Products
            SET Price = @Price
            WHERE Id = @Id";

        await using var updateCommand =
            new SqlCommand(updateSql, connection, transaction);

        updateCommand.Parameters.AddWithValue("@Id", productId);
        updateCommand.Parameters.AddWithValue("@Price", newPrice);

        await updateCommand.ExecuteNonQueryAsync();

        // Commit releases the transaction's locks.
        await transaction.CommitAsync();

        Console.WriteLine("Product updated successfully.");
    }
    catch
    {
        // If something goes wrong, undo the transaction.
        await transaction.RollbackAsync();

        throw;
    }
}
Enter fullscreen mode Exit fullscreen mode

The important part is:

SELECT *
FROM Products WITH (UPDLOCK, ROWLOCK)
WHERE Id = @Id;
Enter fullscreen mode Exit fullscreen mode

combined with a transaction:

await using var transaction =
    (SqlTransaction)await connection.BeginTransactionAsync();
Enter fullscreen mode Exit fullscreen mode

The lock remains relevant for the transaction according to SQL Server's locking and transaction behavior, helping protect the read-then-update operation from competing modifications.


A Simple Pessimistic Locking Scenario

Imagine User A starts this transaction:

BEGIN TRANSACTION

Read Product 1 with an update lock

Update Product 1

COMMIT
Enter fullscreen mode Exit fullscreen mode

While User A's transaction is still active, User B tries to perform a conflicting operation on the same data.

Depending on the operation, isolation level, and locks involved, User B may wait until User A commits or rolls back.

After User A executes:

COMMIT
Enter fullscreen mode Exit fullscreen mode

the transaction completes and its locks are released.

Then User B can continue.


The Danger of Long Transactions

Pessimistic locking can cause problems if transactions stay open for too long.

For example, this is usually a bad idea:

1. Start transaction
2. Lock product
3. Wait for user to fill out a form
4. User goes for lunch
5. Commit transaction
Enter fullscreen mode Exit fullscreen mode

That could cause other users to wait unnecessarily.

Instead, transactions should generally be as short as possible.

A better pattern is:

1. User fills out the form
2. Start transaction
3. Read and validate the current data
4. Update the data
5. Commit immediately
Enter fullscreen mode Exit fullscreen mode

Pessimistic Locking and Deadlocks

Pessimistic locking can increase the possibility of blocking and, in some situations, deadlocks.

A deadlock can look like this:

Transaction A

Locks Product 1
Waits for Product 2
Enter fullscreen mode Exit fullscreen mode

Transaction B

Locks Product 2
Waits for Product 1
Enter fullscreen mode Exit fullscreen mode

Now both transactions are waiting for each other.

SQL Server detects the deadlock and chooses one transaction as the deadlock victim, rolling it back.

One useful strategy for reducing deadlocks is to access resources in a consistent order.

For example, if you always update products in ID order:

Product 1
Product 2
Product 3
Enter fullscreen mode Exit fullscreen mode

rather than sometimes doing:

Product 3
Product 1
Enter fullscreen mode Exit fullscreen mode

you can reduce certain deadlock scenarios.


Optimistic vs Pessimistic Locking

Feature Optimistic Locking Pessimistic Locking
Assumption Conflicts are rare Conflicts are likely or costly
Locks held while user edits No Usually no user-edit-duration lock; locks are held during the transaction
Conflict handling Detect conflict during update Block or serialize conflicting access
Scalability Often better for read-heavy apps Can suffer from blocking
Complexity Requires conflict resolution Requires careful transaction management
Best for Web apps, normal CRUD systems Short critical sections with high contention

Which One Should You Use?

There is no universal answer.

Use Optimistic Locking When

Choose optimistic locking when:

  • Many users mostly read data.
  • Simultaneous updates are relatively rare.
  • Your application can ask users to reload or resolve conflicts.
  • You are building a typical web application or business application.

For many CRUD applications, optimistic concurrency using SQL Server rowversion is a practical starting point.


Use Pessimistic Locking When

Consider pessimistic locking when:

  • Multiple processes frequently modify the same data.
  • Conflicting updates would be especially problematic.
  • You need to protect a short, critical read-modify-write operation.
  • Waiting is preferable to allowing concurrent conflicting work.

Examples might include certain inventory, reservation, or financial workflows—but the exact design should depend on the business rules and transaction requirements.


A Real-World Example: Booking the Last Seat

Suppose there is only one seat left.

Two users try to book it at the same time.

Optimistic Approach

Both users may initially see:

Available seats: 1
Enter fullscreen mode Exit fullscreen mode

The first user successfully updates the record.

The second user's update detects that the data changed.

The second user receives a message:

"Sorry, this seat is no longer available."

Pessimistic Approach

The first transaction locks the relevant data, checks availability, and creates the booking.

A competing transaction attempting a conflicting operation may wait.

Once the first transaction completes, the next transaction checks the latest availability.

If no seat remains, it does not create a booking.

The important point is that the entire operation should be designed as a short atomic transaction.


A Safer Inventory Pattern

For inventory-like scenarios, you can often avoid a separate read followed by an update.

Instead, perform the business rule directly in the UPDATE statement.

UPDATE Products
SET Stock = Stock - 1
WHERE Id = @Id
  AND Stock > 0;
Enter fullscreen mode Exit fullscreen mode

Then check how many rows were affected.

In C#:

public async Task<bool> TryBuyProductAsync(int productId)
{
    var connectionString =
        "Server=.;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";

    await using var connection =
        new SqlConnection(connectionString);

    await connection.OpenAsync();

    var sql = @"
        UPDATE Products
        SET Stock = Stock - 1
        WHERE Id = @Id
          AND Stock > 0";

    await using var command =
        new SqlCommand(sql, connection);

    command.Parameters.AddWithValue("@Id", productId);

    // If affectedRows is 1, stock was available.
    // If affectedRows is 0, either the product does not exist
    // or there was no stock remaining.
    var affectedRows =
        await command.ExecuteNonQueryAsync();

    return affectedRows == 1;
}
Enter fullscreen mode Exit fullscreen mode

This pattern is often simpler and safer than manually locking a row and then performing application-side logic.


A Simple Way to Remember the Difference

Think of a shared document.

Optimistic Locking

Two people can open and edit the document.

When someone saves, the system checks:

"Has somebody else changed this since you opened it?"

If yes, there is a conflict.

Pessimistic Locking

The first person effectively reserves the document for the critical operation.

Other conflicting operations may need to wait until the first person is finished.


Final Thoughts

Concurrency can initially sound complicated, but the core idea is simple:

What should happen when two users try to change the same data at the same time?

Optimistic locking says:

"Let them work independently. Detect the conflict when saving."

Pessimistic locking says:

"Protect the critical section so conflicting operations are coordinated."

For many .NET applications using SQL Server, a good starting point is optimistic concurrency with a rowversion column. It avoids holding long-running locks and works naturally with Entity Framework Core.

For operations where contention is high or correctness requires carefully coordinated access, a short transaction with appropriate locking or an atomic SQL operation may be more suitable.

The key is not simply choosing "optimistic" or "pessimistic." The best solution depends on your application's business rules, how frequently conflicts occur, and what should happen when two users attempt the same operation at the same time.

Key Takeaways

  • Concurrency means multiple users or processes accessing data at the same time.
  • Optimistic locking detects conflicts when data is updated.
  • SQL Server's rowversion is commonly used for optimistic concurrency.
  • Pessimistic locking coordinates conflicting access during a transaction.
  • Keep database transactions as short as possible.
  • Long-running locks can cause blocking and increase the risk of deadlocks.
  • For some scenarios, an atomic SQL statement is better than manually implementing a lock.
  • Always design concurrency around the actual business rule you need to protect.

Top comments (2)

Collapse
 
iqtechsolutions profile image
Ivan Rossouw

One easy place for optimistic concurrency to become accidentally ineffective is a disconnected web API. If a PUT handler re-queries the entity and maps the DTO onto it, EF’s OriginalValue for RowVersion comes from that fresh query—not necessarily from the version the user originally saw—so stale intent may still overwrite newer work.

I prefer carrying the token across the HTTP boundary as an ETag or explicit DTO field, requiring If-Match, and assigning the submitted token as EF’s original value before SaveChangesAsync. The integration test then becomes concrete: two clients GET the same ETag; client A updates successfully; client B sends the old ETag and receives 412. A blind retry is only safe if the business decision itself is replayed against fresh state. Do you normally use 412 here and reserve 409 for conflicts without a request precondition?

Collapse
 
ravi-vishwakarma-hash profile image
Ravi Vishwakarma

Totally agree. I’d usually go with 412 when If-Match fails, since the client’s precondition is no longer true. I’d save 409 for cases where there’s a real business/domain conflict rather than a stale ETag.
And that two-client test is a great way to prove the concurrency check is actually working, not just configured in EF.