Two users open the same product page in your admin panel. User A changes the price from $29.99 to $34.99. User B changes the stock from 150 to 120. They both hit Save within seconds of each other. Without concurrency control, User B’s save silently overwrites User A’s price change - and nobody notices until a customer complains about wrong pricing. This is the lost update problem, and it’s one of the most common data integrity bugs in multi-user applications.
Optimistic concurrency control in EF Core (Entity Framework Core) solves this by detecting conflicts at save time rather than locking rows upfront. It’s lightweight, lock-free, and perfect for web APIs where conflicts are rare but data integrity is non-negotiable.
In this article, I’ll walk through implementing optimistic concurrency in an ASP.NET Core Web API with EF Core 10 and PostgreSQL. I’ll configure RowVersion tokens, handle DbUpdateConcurrencyException with proper 409 Conflict responses, build a retry pattern for automatic conflict resolution, and simulate concurrent requests to see conflicts happen in real time. I’ll also share my decision matrix for when to use optimistic vs pessimistic locking - based on patterns I’ve seen work (and fail) in production. Let’s get into it.
TL;DR. Optimistic concurrency in EF Core 10 is the recommended default for ASP.NET Core Web APIs: add a
uint RowVersionproperty to the entity, configure withentity.Property(p => p.RowVersion).IsRowVersion(), return the version in GET responses, set the original value from the client request beforeSaveChangesAsync, and catchDbUpdateConcurrencyExceptionto return HTTP 409 Conflict with current database values. PostgreSQL maps this toxminfor free (no migration). Use optimistic for 90% of CRUD; reach for pessimistic (SELECT FOR UPDATE) only for financial transactions. The retry-loop gotcha: alwayscontext.Entry(entity).State = EntityState.Detachedbefore re-reading - otherwiseFindAsyncreturns the cached stale entity and you get an infinite exception loop.
What Is Concurrency Control?
Concurrency control is a set of techniques that ensure data consistency when multiple users or processes read and modify the same data simultaneously. In database systems, concurrency conflicts occur when two transactions read the same row, both make changes, and then both try to write back - resulting in one transaction silently overwriting the other’s changes.
There are two fundamental approaches:
- Pessimistic concurrency - Lock the row when it’s read, preventing anyone else from modifying it until the lock is released. Think of it like putting a “do not touch” sign on a row. Works well for short-lived, high-contention operations (bank transfers, seat reservations).
- Optimistic concurrency - Don’t lock anything. Instead, stamp each row with a version token. At save time, check if the version has changed since you read it. If it has, someone else modified the row - throw an exception and let the application decide what to do.
EF Core implements optimistic concurrency natively, as described in the official EF Core concurrency documentation. It does not provide built-in support for pessimistic locking - you’d need raw SQL with SELECT ... FOR UPDATE for that. For most web API scenarios, optimistic concurrency is the right choice because HTTP requests are short-lived and conflicts are statistically rare.
In .NET 10, EF Core 10 uses the same concurrency model as previous versions - concurrency tokens in the
WHEREclause ofUPDATEstatements. The approach is stable and battle-tested.
When to Use What: My Decision Matrix
Before diving into implementation, here’s my decision matrix. I’ve used this in production systems to pick the right approach, and it’s saved me from over-engineering simple scenarios and under-protecting critical ones.
| Scenario | Approach | Why |
|---|---|---|
| Product catalog editing (CMS, admin panels) | Optimistic (RowVersion) | Conflicts are rare - most products are edited by one person at a time. When a conflict happens, show the user both versions and let them choose. |
| Inventory stock updates (e-commerce, warehouse) | Optimistic (RowVersion) + Retry | Stock changes from orders are frequent but short-lived. Retry 2-3 times on conflict - the retry reads fresh data and applies the adjustment. |
| Financial transactions (account balances, ledger entries) | Pessimistic (SELECT FOR UPDATE) | Money can’t be wrong. Lock the row, do the math, commit. The performance cost of locking is acceptable for financial accuracy. |
| Configuration/settings updates | Optimistic (RowVersion) | Config changes are rare and always manual. A simple “someone else changed this, please refresh” is enough. |
| Seat/ticket reservations | Pessimistic or Optimistic + Retry | Depends on volume. Low volume: optimistic with retry. High volume (concert tickets): pessimistic lock for the reservation window. |
| Counters and analytics | None (use atomic SQL) | Don’t use EF Core for UPDATE views SET count = count + 1. Use ExecuteUpdate or raw SQL - no entity loading, no concurrency tokens needed. |
My take: for 90% of ASP.NET Core Web API scenarios, optimistic concurrency with RowVersion is the right default. It adds zero runtime overhead when there’s no conflict (which is most of the time), and it gives you a clean error path when conflicts do occur. The mistake I see most devs make is skipping concurrency control entirely because “our app doesn’t have that many users.” Trust me - it only takes one support ticket about mysteriously overwritten data to make you wish you’d spent the 10 minutes to add a RowVersion column.
Setting Up the Project
I’ll build a Products API that demonstrates optimistic concurrency. The stack:
- .NET 10 with Minimal APIs
- EF Core 10 with PostgreSQL (Npgsql)
- PostgreSQL 17 via Docker
- Scalar for API documentation
Prerequisites
Create the Project
dotnet new web -n ConcurrencyControl.ApiInstall the required NuGet packages:
dotnet add ConcurrencyControl.Api package Microsoft.EntityFrameworkCore --version 10.0.0dotnet add ConcurrencyControl.Api package Npgsql.EntityFrameworkCore.PostgreSQL --version 10.0.0dotnet add ConcurrencyControl.Api package Microsoft.EntityFrameworkCore.Design --version 10.0.0dotnet add ConcurrencyControl.Api package Microsoft.AspNetCore.OpenApi --version 10.0.0dotnet add ConcurrencyControl.Api package Scalar.AspNetCore --version 2.11.9Start PostgreSQL with Docker
Create a docker-compose.yml at the solution root:
services: postgres: image: postgres:17-alpine container_name: postgres-concurrency restart: always environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: concurrency_db ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d concurrency_db"] interval: 10s timeout: 5s retries: 5
volumes: postgres_data:docker compose up -dCreating the Entity with a Concurrency Token
Here’s the Product entity. The key addition is the RowVersion property - this is the concurrency token.
namespace ConcurrencyControl.Api.Entities;
public class Product{ public int Id { get; set; } public string Name { get; set; } = default!; public decimal Price { get; set; } public int Stock { get; set; } public string Category { get; set; } = default!; public DateTime CreatedAt { get; set; } public DateTime? LastModified { get; set; } public uint RowVersion { get; set; }}The RowVersion property is typed as uint because PostgreSQL uses xmin - a 32-bit transaction ID that automatically changes every time a row is modified. On SQL Server, you’d use byte[] with the rowversion data type instead.
Configuring the DbContext
Here’s the AppDbContext with the concurrency token configured via Fluent API:
using ConcurrencyControl.Api.Entities;using Microsoft.EntityFrameworkCore;
namespace ConcurrencyControl.Api.Data;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options){ public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Product>(entity => { entity.HasKey(p => p.Id); entity.Property(p => p.Name).IsRequired().HasMaxLength(200); entity.Property(p => p.Price).HasPrecision(18, 2); entity.Property(p => p.Stock).IsRequired(); entity.Property(p => p.Category).IsRequired().HasMaxLength(100); entity.Property(p => p.CreatedAt).IsRequired();
// Configure RowVersion as a concurrency token // PostgreSQL maps this to the xmin system column entity.Property(p => p.RowVersion) .IsRowVersion(); }); }}The .IsRowVersion() call tells EF Core two things:
- Include this column in the
WHEREclause of everyUPDATEandDELETEstatement - Read the updated value back after each
SaveChanges()so the entity stays in sync
For PostgreSQL, the Npgsql provider maps IsRowVersion() to the xmin system column - a transaction ID that PostgreSQL updates automatically on every row modification. You don’t need to create a migration column for it; xmin already exists on every PostgreSQL table.
Data Annotations Alternative
If you prefer Data Annotations over Fluent API, you can use the [Timestamp] attribute:
using System.ComponentModel.DataAnnotations;
public class Product{ // ... other properties
[Timestamp] public uint RowVersion { get; set; }}Both approaches produce identical behavior. I prefer Fluent API because it keeps entity classes clean and puts all configuration in one place - but this is a style preference, not a technical one.
How EF Core Detects Conflicts: The SQL Behind It
When you call SaveChangesAsync() on a tracked entity with a concurrency token, EF Core generates an UPDATE statement that includes the token in the WHERE clause. Here’s what the actual SQL looks like:
UPDATE "Products"SET "Name" = @p0, "Price" = @p1, "Stock" = @p2, "LastModified" = @p3WHERE "Id" = @p4 AND "xmin" = @p5;Notice the AND "xmin" = @p5 - EF Core is saying “only update this row if the version hasn’t changed since I read it.” If another transaction modified the row between the read and the write, the xmin value will be different, the WHERE clause won’t match any rows, and EF Core detects that zero rows were affected. That’s when it throws DbUpdateConcurrencyException.
This is the beauty of optimistic concurrency - no locks, no blocking, no deadlocks. The “check” happens atomically inside the UPDATE statement itself. When the WHERE clause matches zero rows, EF Core throws DbUpdateConcurrencyException.
To see the generated SQL in development, enable command logging by setting
Microsoft.EntityFrameworkCore.Database.CommandtoInformationin yourappsettings.Development.json.
Registering Services in Program.cs
Wire up EF Core and Scalar in Program.cs:
using ConcurrencyControl.Api.Data;using Microsoft.EntityFrameworkCore;using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();builder.Services.AddDbContext<AppDbContext>(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
var app = builder.Build();
app.MapOpenApi();app.MapScalarApiReference();Add the connection string to appsettings.json:
{ "ConnectionStrings": { "DefaultConnection": "Host=localhost;Database=concurrency_db;Username=postgres;Password=postgres" }}Run the migration:
dotnet ef migrations add Initial --project ConcurrencyControl.Apidotnet ef database update --project ConcurrencyControl.ApiBuilding the API Endpoints
GET Endpoints - Reading Products with RowVersion
When returning products, always include the RowVersion. The client needs it to send back on updates - that’s how conflicts are detected.
app.MapGet("/products", async ( AppDbContext context, CancellationToken ct) =>{ var products = await context.Products .AsNoTracking() .Select(p => new { p.Id, p.Name, p.Price, p.Stock, p.Category, p.RowVersion }) .ToListAsync(ct);
return Results.Ok(products);});The response includes RowVersion for each product. The client stores this value and sends it back when making updates.
PUT Endpoint - Update with Concurrency Check
This is where optimistic concurrency comes to life. The client sends the RowVersion it received when reading the product. Set it as the original value so EF Core includes it in the WHERE clause:
app.MapPut("/products/{id:int}", async ( int id, UpdateProductRequest request, AppDbContext context, CancellationToken ct) =>{ var product = await context.Products.FindAsync([id], ct); if (product is null) return Results.NotFound(new { Error = $"Product with ID {id} not found." });
// Set the original RowVersion so EF Core includes it in the WHERE clause context.Entry(product).Property(p => p.RowVersion).OriginalValue = request.RowVersion;
product.Name = request.Name; product.Price = request.Price; product.Stock = request.Stock; product.Category = request.Category; product.LastModified = DateTime.UtcNow;
try { await context.SaveChangesAsync(ct); return Results.Ok(new { product.Id, product.Name, product.Price, product.Stock, product.Category, product.RowVersion }); } catch (DbUpdateConcurrencyException) { return Results.Conflict(new { Error = "This product was modified by another user. Please refresh and try again.", CurrentVersion = (await context.Products.AsNoTracking() .Where(p => p.Id == id) .Select(p => new { p.RowVersion, p.Name, p.Price, p.Stock }) .FirstOrDefaultAsync(ct)) }); }});
public record UpdateProductRequest( string Name, decimal Price, int Stock, string Category, uint RowVersion);Code Walkthrough
Setting the original RowVersion - context.Entry(product).Property(p => p.RowVersion).OriginalValue = request.RowVersion is the critical line. When EF Core generates the UPDATE, it uses the original value of the concurrency token in the WHERE clause. Setting it to the value the client sent ensures the update only succeeds if the row hasn’t been modified since the client last read it.
Catching DbUpdateConcurrencyException - When the WHERE clause doesn’t match (because someone else changed the row), SaveChangesAsync() throws DbUpdateConcurrencyException. Catch it and return a 409 Conflict response with the current database values so the client can show both versions to the user.
Returning the new RowVersion - On success, the endpoint returns the updated RowVersion. The client should store this new value for subsequent updates.
Conflict Resolution Strategies
When a DbUpdateConcurrencyException occurs, you have three options for resolution. The right choice depends on your domain.
Strategy 1: Reject and Notify (Client Wins)
This is what the PUT endpoint above does - reject the update and tell the client to refresh. Best for scenarios where a human is making the change and should see what the other person changed.
catch (DbUpdateConcurrencyException){ return Results.Conflict(new { Error = "Data was modified by another user. Please refresh and retry." });}Strategy 2: Retry with Fresh Data (Last-Write-Wins)
For automated operations like stock adjustments, you often want to retry automatically. Read the latest data, re-apply the change, and try again:
app.MapPatch("/products/{id:int}/stock-with-retry", async ( int id, StockAdjustmentRequest request, AppDbContext context, CancellationToken ct) =>{ const int maxRetries = 3;
for (int attempt = 0; attempt < maxRetries; attempt++) { var product = await context.Products.FindAsync([id], ct); if (product is null) return Results.NotFound(new { Error = $"Product with ID {id} not found." });
product.Stock += request.Adjustment; if (product.Stock < 0) return Results.BadRequest(new { Error = "Insufficient stock." });
product.LastModified = DateTime.UtcNow;
try { await context.SaveChangesAsync(ct); return Results.Ok(new { product.Id, product.Stock, product.RowVersion, Attempt = attempt + 1 }); } catch (DbUpdateConcurrencyException) { // Detach the entity to re-read fresh data on next iteration context.Entry(product).State = EntityState.Detached; } }
return Results.Conflict(new { Error = $"Failed to update stock after {maxRetries} attempts." });});
public record StockAdjustmentRequest(int Adjustment);The key here is context.Entry(product).State = EntityState.Detached - this removes the stale entity from the change tracker so the next FindAsync reads fresh data from the database. Without detaching, EF Core would return the cached (stale) entity.
Strategy 3: Merge Changes
The most complex option - read both the current database values and the proposed values, then merge them. This is useful for CMS-style editing where different users might be editing different fields:
catch (DbUpdateConcurrencyException ex){ var entry = ex.Entries.Single(); var databaseValues = await entry.GetDatabaseValuesAsync(ct); var proposedValues = entry.CurrentValues;
// Example: keep the database value for Price, but use the proposed value for Stock foreach (var property in proposedValues.Properties) { var proposedValue = proposedValues[property]; var databaseValue = databaseValues![property];
// Your merge logic here proposedValues[property] = proposedValue; // or databaseValue }
// Update the original values to bypass the next concurrency check entry.OriginalValues.SetValues(databaseValues!); await context.SaveChangesAsync(ct);}My take: start with Strategy 1 (reject and notify) for user-facing endpoints, and Strategy 2 (retry) for automated/background operations. Strategy 3 (merge) sounds elegant but in practice it’s fragile - merging arbitrary field changes without domain knowledge leads to subtle bugs. If you need merge, do it at the domain level with explicit rules, not generically over all properties.
Simulating Concurrent Requests
Let’s prove this works. Here’s an endpoint that fires 5 concurrent updates to the same product - only one should succeed:
app.MapPost("/products/{id:int}/simulate-conflict", async ( int id, AppDbContext context, IServiceProvider sp, CancellationToken ct) =>{ var product = await context.Products.AsNoTracking() .FirstOrDefaultAsync(p => p.Id == id, ct);
if (product is null) return Results.NotFound(new { Error = $"Product with ID {id} not found." });
// Fire 5 concurrent updates to the same product var tasks = Enumerable.Range(1, 5).Select(async i => { using var scope = sp.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var p = await db.Products.FindAsync([id], ct); if (p is null) return new { Task = i, Status = "NotFound", Detail = (string?)null };
p.Price = product.Price + (i * 1.00m); p.LastModified = DateTime.UtcNow;
await Task.Delay(10, ct); // Small delay to increase conflict chance
try { await db.SaveChangesAsync(ct); return new { Task = i, Status = "Success", Detail = $"Price updated to {p.Price}" }; } catch (DbUpdateConcurrencyException) { return new { Task = i, Status = "Conflict", Detail = "DbUpdateConcurrencyException caught" }; } });
var results = await Task.WhenAll(tasks);
return Results.Ok(new { Message = "Concurrent update simulation complete", Successes = results.Count(r => r.Status == "Success"), Conflicts = results.Count(r => r.Status == "Conflict"), Details = results });});When you hit POST /products/1/simulate-conflict, you’ll see something like:
{ "message": "Concurrent update simulation complete", "successes": 1, "conflicts": 4, "details": [ { "task": 1, "status": "Success", "detail": "Price updated to 30.99" }, { "task": 2, "status": "Conflict", "detail": "DbUpdateConcurrencyException caught" }, { "task": 3, "status": "Conflict", "detail": "DbUpdateConcurrencyException caught" }, { "task": 4, "status": "Conflict", "detail": "DbUpdateConcurrencyException caught" }, { "task": 5, "status": "Conflict", "detail": "DbUpdateConcurrencyException caught" } ]}Only one of the five concurrent updates succeeds. The rest correctly detect the conflict and throw DbUpdateConcurrencyException. That’s exactly the desired behavior - no silent data overwrites, no lost updates.
Each task creates its own DbContext scope via IServiceProvider.CreateScope(). This is critical - if they shared a DbContext, EF Core’s change tracker would serialize the operations, and you’d never see a conflict.
RowVersion vs ConcurrencyCheck: When to Use Each
EF Core offers two ways to configure concurrency tokens:
| Feature | RowVersion (IsRowVersion) | ConcurrencyCheck (IsConcurrencyToken) |
|---|---|---|
| Value management | Database auto-generates and auto-updates | Application must set the value manually |
| Scope | Protects the entire row - any column change triggers a version bump | Protects only the marked column |
| Column required | Yes - requires a version column (or xmin on PostgreSQL) | No extra column - uses existing properties |
| Database support | SQL Server (rowversion), PostgreSQL (xmin), MySQL (DATETIME trigger) | All databases |
| Risk of bugs | Low - version updates automatically | High - forgetting to set the new value means no conflict detection |
My take: always default to IsRowVersion() for SQL Server and PostgreSQL APIs. IsConcurrencyToken() is useful in exactly one scenario - when your database doesn’t support auto-updating version columns (like SQLite). In every other case, IsRowVersion() is safer because the database handles version updates automatically. I’ve seen production bugs where developers used IsConcurrencyToken(), forgot to update the token value in one code path, and shipped a silent data corruption bug that went unnoticed for weeks.
ConcurrencyCheck Example (For Reference)
If you do need application-managed tokens - for example, with SQLite:
public class Product{ // ... other properties public Guid Version { get; set; }}
// Fluent APIentity.Property(p => p.Version).IsConcurrencyToken();
// You MUST set this manually on every update:product.Version = Guid.NewGuid();await context.SaveChangesAsync(ct);If you forget the product.Version = Guid.NewGuid() line, the concurrency check silently passes even when there’s a conflict. That’s why I recommend avoiding this approach unless you have a specific reason.
PostgreSQL xmin vs SQL Server rowversion
If you’re coming from a SQL Server background, here’s how PostgreSQL’s approach differs:
| Aspect | SQL Server rowversion | PostgreSQL xmin |
|---|---|---|
| Type | byte[] (8 bytes, binary) | uint (32-bit transaction ID) |
| Storage | Explicit column you add to the table | System column - already exists on every row |
| Migration needed | Yes - ALTER TABLE ADD | No - xmin is built-in |
| Uniqueness | Unique across the database | Unique within the transaction scope |
| Wraps around | Never (binary counter) | Yes - after ~4 billion transactions (PostgreSQL handles this via VACUUM) |
| EF Core config | entity.Property(p => p.Version).IsRowVersion() | Same - Npgsql maps it to xmin automatically |
The Npgsql provider handles the mapping transparently. When you call .IsRowVersion() on a uint property, Npgsql maps it to PostgreSQL’s xmin system column instead of creating a new column. This means you get concurrency protection for free on every table - no migration needed for the version column itself.
For SQL Server, the entity would look slightly different:
// SQL Server versionpublic class Product{ // ... other properties
[Timestamp] public byte[] RowVersion { get; set; } = default!;}Production Gotchas
Here are the issues that bit me in real projects. Saving you the debugging time.
1. Bulk Operations Bypass Concurrency Tokens
ExecuteUpdate() and ExecuteDelete() do not check concurrency tokens. They translate directly to SQL without going through the change tracker:
// This does NOT check RowVersion - all matching rows are updatedawait context.Products .Where(p => p.Category == "Electronics") .ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, p => p.Price * 1.1m), ct);If you need concurrency protection on bulk operations, you have to implement it manually with raw SQL or process entities individually through the change tracker.
2. Concurrency Tokens on Related Entities
A RowVersion on a Product doesn’t protect its ProductImages or ProductReviews. If User A adds an image to a product and User B changes the product name - the product’s RowVersion changes from the name update, but the image addition doesn’t trigger a concurrency check on the product.
If you need to protect related entities, either:
- Add
RowVersionto each entity that needs protection - Use
LastModifiedon the parent and update it when children change (via interceptors)
3. Detach Entities Before Retry
When retrying after a DbUpdateConcurrencyException, you must detach the stale entity before re-reading:
catch (DbUpdateConcurrencyException){ context.Entry(product).State = EntityState.Detached; // Now FindAsync will read fresh data from the database}Without detaching, FindAsync returns the cached entity from the change tracker - with the stale RowVersion - and you’ll get an infinite loop of concurrency exceptions.
4. Soft Deletes and Concurrency
If you’re using soft deletes with global query filters, a “deleted” product’s RowVersion still changes when the IsDeleted flag is set. If another user tries to update the same product concurrently, they’ll get a DbUpdateConcurrencyException - which is correct behavior, but you should handle it with a clear message like “This product was deleted by another user.”
5. Migrations and Existing Data
When adding a RowVersion column to an existing SQL Server table, all existing rows start with a version value. That’s fine - conflicts only occur when two transactions read the same version and both try to update. The initial version value doesn’t matter.
For PostgreSQL with xmin, there’s nothing to migrate - the system column already exists.
Key Takeaways
- Optimistic concurrency in EF Core adds the concurrency token to the
WHEREclause ofUPDATEstatements - no locks, no blocking, no deadlocks. - Use
IsRowVersion()for SQL Server and PostgreSQL. It’s the safest approach because the database manages version values automatically. - Return 409 Conflict with current database values when a
DbUpdateConcurrencyExceptionoccurs in your API. - Retry with detach for automated operations - detach the stale entity, re-read fresh data, re-apply the change.
- Bulk operations bypass concurrency tokens -
ExecuteUpdateandExecuteDeletedon’t go through the change tracker.
What is optimistic concurrency in Entity Framework Core?
Optimistic concurrency is a conflict detection strategy where EF Core does not lock database rows. Instead, it stamps each row with a version token and checks that token during SaveChanges. If the token has changed since the entity was read, EF Core throws DbUpdateConcurrencyException, indicating another transaction modified the row.
How does EF Core detect concurrency conflicts?
EF Core adds the concurrency token to the WHERE clause of UPDATE and DELETE statements. For example: UPDATE Products SET Name = @p0 WHERE Id = @p1 AND xmin = @p2. If the WHERE clause matches zero rows because the version changed, EF Core knows a conflict occurred and throws DbUpdateConcurrencyException.
What is the difference between optimistic and pessimistic locking in EF Core?
Optimistic locking checks for conflicts at save time without acquiring locks. Pessimistic locking locks the row when it is read, preventing other transactions from modifying it. EF Core only supports optimistic locking natively. For pessimistic locking, you need raw SQL with SELECT FOR UPDATE or database-specific locking hints.
How do I handle DbUpdateConcurrencyException in ASP.NET Core?
Catch DbUpdateConcurrencyException in a try-catch block around SaveChangesAsync. For user-facing APIs, return HTTP 409 Conflict with the current database values so the client can refresh. For automated operations, detach the stale entity, re-read fresh data, and retry the operation up to a maximum number of attempts.
Should I use RowVersion or ConcurrencyCheck attribute?
Use IsRowVersion (or the Timestamp attribute) for SQL Server and PostgreSQL. The database manages version values automatically, eliminating the risk of forgetting to update the token. Use IsConcurrencyToken (ConcurrencyCheck attribute) only for databases that do not support auto-updating version columns, like SQLite.
Does optimistic concurrency work with PostgreSQL and EF Core?
Yes. The Npgsql provider maps IsRowVersion to PostgreSQL's xmin system column, a 32-bit transaction ID that changes automatically on every row update. You do not need to add a version column to your tables because xmin is a built-in system column on every PostgreSQL table.
How do I implement retry logic for concurrency conflicts?
Wrap your update logic in a for loop with a maximum retry count. On each DbUpdateConcurrencyException, set the entity state to Detached to clear the change tracker, then re-read the entity with FindAsync to get fresh data. Re-apply your changes and call SaveChangesAsync again. Three retries is usually sufficient for most scenarios.
What HTTP status code should I return for concurrency conflicts in a Web API?
Return HTTP 409 Conflict. This status code indicates that the request could not be completed because of a conflict with the current state of the resource. Include the current database values in the response body so the client can show the user what changed and let them decide how to proceed.
Troubleshooting
DbUpdateConcurrencyException on every update - You’re likely setting the wrong RowVersion value. Make sure the client sends back the exact RowVersion it received from the GET endpoint. On PostgreSQL, this is a uint; on SQL Server, it’s a byte[] (often Base64-encoded in JSON).
Concurrency exception not being thrown - Check that you configured the property with .IsRowVersion() or [Timestamp]. Without this configuration, EF Core won’t include the version in the WHERE clause. Also verify the entity is being tracked - AsNoTracking() queries don’t participate in concurrency checks.
Infinite retry loop - You forgot to detach the entity before retrying. Without context.Entry(entity).State = EntityState.Detached, FindAsync returns the cached entity with the stale RowVersion, causing the same exception on every retry.
PostgreSQL xmin wraps to 0 - PostgreSQL’s xmin is a 32-bit transaction counter. After approximately 4 billion transactions, it wraps around. PostgreSQL’s VACUUM process handles this transparently - you don’t need to do anything. This is a PostgreSQL internal concern, not an EF Core issue.
409 Conflict responses in production logs - This is expected behavior, not an error. Don’t log concurrency conflicts at Error level - use Warning or Information. They indicate the system is working correctly by detecting concurrent modifications.
Summary
Optimistic concurrency in EF Core 10 is straightforward to implement and gives you robust protection against lost updates - with zero performance overhead when there’s no conflict. Add a RowVersion property, configure it with .IsRowVersion(), catch DbUpdateConcurrencyException, and return a proper 409 Conflict response. That’s it.
The entire source code for this article is available on GitHub.
If you found this helpful, share it with your colleagues - and if there’s a topic you’d like to see covered next, drop a comment and let me know. Don’t forget to subscribe to the newsletter for weekly .NET content with judgment calls, benchmarks, and real-world patterns - not just tutorials.
Happy Coding :)



What's your Feedback?
Do let me know your thoughts around this article.