Two users open the same customer record at 10:02 AM.
User A changes the email address. User B changes the phone number.
User A saves first. User B saves a second later.
What happens? User B’s save overwrites User A’s change.
The email update is gone. Nobody got an error. No warning. Just silent data loss.
This is the lost update problem. It happens in every multi-user application that doesn’t have a concurrency strategy. And in most applications I’ve worked on, there was no strategy at all.
Optimistic locking is how you fix it in EF Core - without adding database-level locks that kill performance under load.
Optimistic vs Pessimistic Locking
Before jumping to EF Core code, it’s worth understanding the two approaches, because choosing the wrong one makes things worse.
Pessimistic locking acquires a database lock when a user starts editing a record. Nobody else can edit it until the lock is released. It prevents conflicts by design but it’s expensive. If a user opens a form, walks away for lunch, and comes back 45 minutes later, that row stays locked the entire time. In a application with many concurrent users, this creates contention fast.
Optimistic locking doesn’t lock anything. Instead, it tracks a version of the record when you load it. When you save, it checks whether that version has changed. If someone else saved in between, the save fails and you decide what to do.
The bet you’re making is that conflicts are rare. In most business applications like HR systems, CRM, ERP - that bet is correct. Most users aren’t editing the same record at the same time. Optimistic locking gives you conflict detection at nearly zero cost under normal conditions.
Use pessimistic locking when conflicts are frequent and correctness is critical like decrementing inventory on an e-commerce site. For typical form-based CRUD in a application, optimistic locking is the right default.
How EF Core Implements Optimistic Locking
EF Core supports optimistic locking through a concurrency token - a column value that changes every time the row is updated.
When EF Core generates the UPDATE SQL, it includes that token in the WHERE clause:
UPDATE Customers
SET Email = '[email protected]'
WHERE Id = 42 AND RowVersion = 0x0000000000001A3F
If the row was modified by someone else since you loaded it, the
RowVersionhas changed. TheWHEREclause matches zero rows. EF Core sees zero rows affected and throwsDbUpdateConcurrencyException.
That’s the entire mechanism. No magic, no background polling - just a version check on every write.
Setting It Up in SQL Server
SQL Server has a built-in column type for this: rowversion (also known as timestamp). It’s an 8-byte value the database automatically increments whenever the row changes. You don’t manage it - SQL Server does.
Option 1: Data Annotations
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; }
}
The [Timestamp] attribute tells EF Core to treat this column as a concurrency token and map it to a rowversion column.
Option 2: Fluent API (preferred for clean entities) - I use this!
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
public void Configure(EntityTypeBuilder<Customer> builder)
{
builder.Property(c => c.RowVersion)
.IsRowVersion();
}
}
Both approaches produce the same migration:
migrationBuilder.AddColumn<byte[]>(
name: "RowVersion",
table: "Customers",
rowVersion: true,
nullable: false,
defaultValue: new byte[] { });
That’s all you need on the setup side.
Handling DbUpdateConcurrencyException
When a conflict is detected, EF Core throws DbUpdateConcurrencyException. If you don’t catch it, your user gets a 500 error with no explanation. That’s not great.
You have three strategies for resolving the conflict. Which one you choose depends on your business rules.
Strategy 1: Database Wins (Discard Client Changes)
The safest option. Throw away what the client tried to save, reload the current database state, and tell the user to try again.
try
{
await dbContext.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
await entry.ReloadAsync(); // Reload current DB values
// Return 409 Conflict — let the client handle it
throw new ConflictException("This record was modified by another user. Please reload and try again.");
}
This is the most common approach. Simple to implement, and it protects data integrity.
Strategy 2: Client Wins (Overwrite Anyway)
You decide the client’s changes should always win, regardless of what happened in between. Use with care - this reintroduces the lost update problem on purpose.
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var databaseValues = await entry.GetDatabaseValuesAsync();
// Update original values to the current DB state so EF skips the version check
entry.OriginalValues.SetValues(databaseValues);
await dbContext.SaveChangesAsync(); // Now it saves without conflict
}
Only use this if you’ve consciously decided that the current user’s changes take priority - for example, an admin override scenario.
Strategy 3: Property-Level Merge
You inspect each property individually and decide which value to keep. This is complex and rarely worth implementing unless your UI actually shows users a “merge conflict” screen.
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var databaseValues = await entry.GetDatabaseValuesAsync();
var proposedValues = entry.CurrentValues;
foreach (var property in entry.Properties)
{
var databaseValue = databaseValues[property.Metadata];
var proposedValue = proposedValues[property.Metadata];
// Your merge logic here — e.g., take the database value for most fields
// but keep the proposed value for the field the user explicitly changed
proposedValues[property.Metadata] = proposedValue; // or databaseValue
}
// Set original values to current DB to clear the conflict
entry.OriginalValues.SetValues(databaseValues);
await dbContext.SaveChangesAsync();
}
For most application scenarios, Strategy 1 is the right default. Return a
409 Conflictresponse and let the user decide what to do.
Keep the Token in the Loop
This is where most implementations break.
The concurrency token has to round-trip from the server to the client and back. If you load a Customer, return a DTO, let the user edit for 5 minutes, and then save. EF Core needs the original RowVersion from when the record was loaded, not a new one fetched at save time.
The DTO
public class CustomerDto
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public string RowVersion { get; set; } // Base64-encoded for JSON transport
}
The GET endpoint
// GET /api/customers/42
var customer = await dbContext.Customers.FindAsync(id);
return new CustomerDto
{
Id = customer.Id,
Name = customer.Name,
Email = customer.Email,
RowVersion = Convert.ToBase64String(customer.RowVersion)
};
The PUT endpoint
// PUT /api/customers/42
public async Task<IActionResult> Update(int id, CustomerDto dto)
{
var customer = await dbContext.Customers.FindAsync(id);
if (customer == null) return NotFound();
customer.Name = dto.Name;
customer.Email = dto.Email;
// Tell EF Core what version the client thinks it has
dbContext.Entry(customer)
.Property(c => c.RowVersion)
.OriginalValue = Convert.FromBase64String(dto.RowVersion);
try
{
await dbContext.SaveChangesAsync();
return Ok();
}
catch (DbUpdateConcurrencyException)
{
return Conflict("Record was modified by another user.");
}
}
If you forget to set OriginalValue from the DTO’s token, EF Core will use the version it loaded from the database - which is always current and the concurrency check never fires. You’ll think it’s working until it isn’t.
Using a Non-rowversion Column as a Token
Sometimes you’re working with an existing schema you can’t change. You can’t add a rowversion column. In that case, you can mark any existing column as a concurrency token with [ConcurrencyCheck] or the Fluent API equivalent.
A common candidate is a LastModified datetime column:
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
[ConcurrencyCheck]
public DateTime LastModified { get; set; }
}
EF Core will include this in the WHERE clause on updates:
UPDATE Customers
SET Name = 'New Name', LastModified = '2025-01-15 10:30:00'
WHERE Id = 42 AND LastModified = '2025-01-15 09:00:00'
Note: SQL Server’s datetime type has ~3.33ms precision. Two rapid saves within the same tick will have the same LastModified value, and the conflict won’t be detected. If you’re using this approach, make sure the column is datetime2, which has 100-nanosecond precision.
rowversion is still better - it’s automatic, precise, and has no edge cases. Use [ConcurrencyCheck] only when you can’t add a new column.
What Not to Do
Don’t swallow the exception silently.
// ❌ Don't do this
catch (DbUpdateConcurrencyException)
{
// Just ignore it
}
You’ve now added concurrency detection and immediately thrown it away. The user thinks their save worked. Their changes were lost. This is worse than not having concurrency at all because it creates false confidence.
Don’t use optimistic locking on high-contention records.
If 50 users are modifying the same row simultaneously like a shared counter or a stock quantity - optimistic locking means most of those saves will throw exceptions. You’ll spend more time handling retries than doing actual work. For high-contention scenarios, look at SQL Server’s SKIP LOCKED, queue-based updates, or application-level serialization.
Don’t forget to return the token in API responses.
If a PUT succeeds, the RowVersion has changed. If your client caches the old token and tries to do a second update without refreshing, it will get a 409 even though no conflict occurred. Always return the new RowVersion after a successful save.
Final Thoughts
Optimistic locking is a one-time setup for significant protection. The EF Core needs maybe 20 lines. The main work is deciding what to do when a conflict happens - which is a product decision, not a technical one.
In most applications, conflicts are genuinely rare. The data loss they cause, when there’s no concurrency strategy at all, isn’t rare - it’s just invisible.
The setup takes an afternoon. The protection is permanent.
If you’re working on an application with no concurrency handling, add rowversion to your most-edited tables first - Products, Orders, Customers - wherever two users editing simultaneously would cause real problems. That’s where the risk is.