- Published on
Implementing Optimistic Concurrency in EF Core Using DateTime Timestamps Instead of RowVersion
- Authors
- Name
- Ivan Gechev
In the world of multi-user applications, there's a silent data corruption problem lurking in the shadows of every database update. This is the lost update problem. Imagine that two users load the same record, make different changes, and the last one to save silently overwrites the first person's work. No error. No warning. Just gone.
In this post, we'll build a complete implementation from scratch. This will include entity configuration, automatic timestamp updates built in the SaveChangesAsync()
method, a service layer with proper conflict detection, and a minimal API endpoint that returns 409 Conflict
when someone tries to save stale data.
Let's dive in!
Our Cat-Related API and the Lost Update Problem
Before we can explore what optimistic concurrency is, let's take a closer look at how our application looks:
public class Cat
{
public int Id { get; set; }
public required string Name { get; set; }
public required string Breed { get; set; }
public required int Age { get; set; }
}
public class CatDbContext(DbContextOptions<CatDbContext> options)
: DbContext(options)
{
public virtual DbSet<Cat> Cats { get; set; }
}
Here we have the same Cat
class and basic CatDbContext
used in previous examples.
This time, we also have a service layer:
public interface ICatService
{
Task<CatResponse?> GetCatByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<CatResponse>> GetAllCatsAsync(CancellationToken cancellationToken = default);
Task<CatResponse> CreateCatAsync(CreateCatRequest cat, CancellationToken cancellationToken = default);
Task UpdateCatAsync(int id, UpdateCatRequest request, CancellationToken cancellationToken = default);
Task DeleteCatAsync(int id, CancellationToken cancellationToken = default);
}
public class CatService(CatDbContext context) : ICatService
{
// ...
public async Task UpdateCatAsync(
int id,
UpdateCatRequest request,
CancellationToken cancellationToken = default)
{
var cat = await context.Cats.FindAsync([id], cancellationToken);
if (cat is null)
{
throw new KeyNotFoundException(
$"Cat with ID {id} not found");
}
cat.Name = request.Name;
cat.Breed = request.Breed;
cat.Age = request.Age;
await context.SaveChangesAsync(cancellationToken);
}
}
The ICatService
interface and the CatService
class have the basic method for all CRUD operations. I've omitted all method implementations except the one for the UpdateCatAsync()
method as it's the focus on this topic.
And now, the final piece:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<CatDbContext>(options =>
options.UseInMemoryDatabase("CatsDb"));
builder.Services.AddScoped<ICatService, CatService>();
var app = builder.Build();
app.UseHttpsRedirection();
app.MapPut("cats/{id:int}", async (
[FromRoute] int id,
[FromBody] UpdateCatRequest request,
[FromServices] ICatService catService,
CancellationToken cancellationToken) =>
{
try
{
await catService.UpdateCatAsync(id, request, cancellationToken);
return Results.NoContent();
}
catch (KeyNotFoundException)
{
return Results.NotFound(new { error = $"Cat with ID {id} not found" });
}
})
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
app.Run();
In our Program
class, we register our database and service classes. And again, we only see the PUT
endpoint implementations as it's the only one that concerns us.
With how our system is built at the moment, there is nothing that prevents data corruption in multi-user scenarios. Next, let's see what our options are.
Understanding Optimistic Concurrency with RowVersion vs DateTime Approaches
The traditional approach of implementing optimistic concurrency is to utilize the RowVersion
:
public class Cat
{
public int Id { get; set; }
public required string Name { get; set; }
public required string Breed { get; set; }
public required int Age { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; } = null!;
}
This approach works fine. We get an automatic value generation and guaranteed uniqueness. But it has some cons as well - it works only in SQL Server and produces a meaningless (for us mortals) byte array.
Let's look at the approach we'll take:
public class Cat
{
public int Id { get; set; }
public required string Name { get; set; }
public required string Breed { get; set; }
public required int Age { get; set; }
[ConcurrencyCheck]
public required DateTime LastModifiedAt { get; set; }
}
When using DateTime
together with the ConcurrencyCheck
attribute we get the same behavior but with added value. We now have a human-readable value over which we have total control. This can work as an audit trail as we can easily spot when was the last edit.
here.
If you are interested in having a deep dive into Minimal APIs, you can check my Minimal APIs in ASP.NET Core course
And last, but not least is the fact that this approach will work with any database provider - PostgreSQL, MySQL, SQLite, etc.
Step-by-Step Implementation of Optimistic Concurrency
We'll start by creating an abstraction:
public interface IHasLastModifiedAt
{
public DateTime LastModifiedAt { get; set; }
}
As it's most likely that we'll have more than one entity in our database that we'll want to prevent the lost update problem on, we create the IHasLastModifiedAt
interface. It has the already familiar LastModifiedAt
property.
Now let's do a minor tweak:
public class Cat : IHasLastModifiedAt
{
// ...
[ConcurrencyCheck]
public required DateTime LastModifiedAt { get; set; }
}
We already saw what we'll use in our Cat
class but we also have to implement the new IHasLastModifiedAt
interface.
If you wonder why we use the required
keyword for the LastModifiedAt
property - we prevent DateTime.MinValue
and force initialization before insert.
Now, let's make sense of why we need it:
public class CatDbContext(DbContextOptions<CatDbContext> options)
: DbContext(options)
{
// ...
public override async Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
UpdateTimestamps();
return await base.SaveChangesAsync(cancellationToken);
}
private void UpdateTimestamps()
{
var entries = ChangeTracker.Entries<IHasLastModifiedAt>()
.Where(e => e.State == EntityState.Modified);
var now = DateTime.UtcNow;
foreach (var entry in entries)
{
entry.Entity.LastModifiedAt = now;
}
}
}
We start by overriding the SaveChangesAsync()
method. Inside, before saving the changes, we invoke the UpdateTimestamps()
method.
It's a method we define ourselves. There are several things that happen inside. First, we use the ChangeTracker
's Entries<T>()
in combination with the Where()
method to get all modified entries that implement our IHasLastModifiedAt
interface. Then, we iterate over them and set their LastModifiedAt
property to the current timestamp.
This prevents us from forgetting to update the value by automatically doing so on saving. Neat!
Let's take a look at what this works under the hood:
UPDATE Cats
SET Name = 'Mr. Whiskers',
Age = 4,
LastModifiedAt = '2025-10-17 14:30:00' -- New timestamp
WHERE Id = 1
AND LastModifiedAt = '2025-10-17 14:00:00' -- ← Original timestamp
The query generated adds LastModifiedAt
with the original timestamp to the WHERE
clause, ensuring that the entity has not been updated in the meantime.
You might prefer to use the fluent API to configure your entities instead of attributes. Let's take a quick look at how to do so
public class CatDbContext(DbContextOptions<CatDbContext> options)
: DbContext(options)
{
// ...
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Cat>(builder =>
{
builder.Property(c => c.LastModifiedAt)
.IsConcurrencyToken(); // Same as [ConcurrencyCheck] attribute
});
}
Utilizing the IsConcurrencyToken()
method does the same as the ConcurrencyCheck
attribute, so it's up to you to decide which to use.
Updating Our Service Layer to Better Accommodate Optimistic Concurrency
First, let's create a custom exception:
public class ConcurrencyException : Exception
{
public ConcurrencyException(string message)
: base(message)
{
}
public ConcurrencyException(string message, Exception innerException)
: base(message, innerException)
{
}
}
We define the ConcurrencyException
and create two different constructors - both have a message parameter, but the second has an Exception
one as well.
Next, we update our contract:
public record UpdateCatRequest(
string Name,
string Breed,
int Age,
DateTime LastModifiedAt);
We do so by adding a DateTime
property and naming it LastModifiedAt
.
Let's move to the UpdateCatAsync()
method:
public class CatService(CatDbContext context) : ICatService
{
// ...
public async Task UpdateCatAsync(
int id,
UpdateCatRequest request,
CancellationToken cancellationToken = default)
{
var cat = await context.Cats.FindAsync([id], cancellationToken);
if (cat is null)
{
throw new KeyNotFoundException(
$"Cat with ID {id} not found");
}
if (cat.LastModifiedAt != request.LastModifiedAt)
{
throw new ConcurrencyException(
$"Cat with ID {id} was modified by another user.");
}
cat.Name = request.Name;
cat.Breed = request.Breed;
cat.Age = request.Age;
try
{
await context.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
throw new ConcurrencyException(
$"Cat with ID {id} was modified by another user.");
}
}
}
We start by adding an if
statement that comparing the LastModifiedAt
properties of our request and entity. If they are different, it means that someone has updated the entity before us and we are using stale data. In this case, we throw our ConcurrencyException
with a meaningful message. In other words, this is the early detection layer that catches conflicts BEFORE we attempt to save the changes.
If the two properties are the same, we continue with our method. Note that we DON'T update LastModifiedAt
here because it will be automatically updated when the SaveChangesAsync()
method is executed.
After this is done, we wrap the invocation of SaveChangesAsync()
in a try-catch
block. This is intended to catch database-level concurrency exceptions. This is our second layer of defense that catches an 'impossible' race condition. It occurs when two different threads both successfully pass the manual timestamp check at nearly the same time (because they loaded the same database value), but only one can succeed at the database level where the UPDATE
's WHERE
clause provides atomic protection.
The manual check improves our developer experience and performance (fail fast, clear errors), while the database check is about correctness and safety (atomic guarantee, race condition protection). Together, they provide us with a robust, production-ready concurrency control.
And now, our last update:
app.MapPut("cats/{id:int}", async (
[FromRoute] int id,
[FromBody] UpdateCatRequest request,
[FromServices] ICatService catService,
CancellationToken cancellationToken) =>
{
try
{
await catService.UpdateCatAsync(id, request, cancellationToken);
return Results.NoContent();
}
catch (KeyNotFoundException)
{
return Results.NotFound(new { error = $"Cat with ID {id} not found" });
}
catch (ConcurrencyException ex)
{
return Results.Conflict(new
{
error = "Concurrency conflict",
message = ex.Message,
detail = "The cat was modified by another user. Please reload and try again."
});
}
})
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
In our PUT
endpoint, we add another catch
statement that will catch any ConcurrencyException
that might occur. Inside, we ensure that a proper response is returned to the user. We also add another call to the Produces()
method to document that our endpoint might also return StatusCodes.Status409Conflict
.
Conclusion
And there you have it - a robust optimistic concurrency implementation that works across all database providers!
By using DateTime
timestamps with the ConcurrencyCheck
attribute, we get human-readable audit information and automatic updates via SaveChangesAsync()
. Our dual-layer protection catches conflicts both early and reliably, while proper exception handling returns meaningful 409 Conflict
responses.
The beauty of this approach is its simplicity - no impossible to interpret byte arrays, just clear timestamps. In multi-user applications, the lost update problem is real, but with optimistic concurrency properly implemented, your data stays safe without the overhead of pessimistic locking.