Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 020 / 100

Handling concurrency in EF Core: lessons from production

Two admins edit the same order. Who wins?

Expona Admin7 min read

Handling concurrency in EF Core

Two admins open the same order in your system. Admin A updates the shipping address. Admin B updates the payment status. When both hit save within seconds of each other, one change vanishes without warning. This silent data corruption is the lost update problem, and it's waiting to trip up any production system that doesn't protect against concurrent edits.

In this article, I'll walk you through the concurrency mechanisms built into Entity Framework Core, show you how to detect and recover from conflicts, and share the PostgreSQL trick that gives you free optimistic locking.

The Lost Update Problem

Imagine you're building an order management system. An order object holds status, shipping address, payment method, and notes. The database state looks clean at 2:00 PM:

OrderId Status ShippingAddress PaymentMethod
42 Processing 123 Main St Card ending 4242

Admin A loads the order at 2:00:10 PM and changes the address to 456 Oak Ave. Admin B loads the same order at 2:00:15 PM and changes the payment to "Card ending 5555". Both queries read the original row version. If there's no concurrency protection:

  1. Admin A's UPDATE runs at 2:00:20 PM. The row now has status=Processing, address=456 Oak Ave, payment=Card ending 4242.
  2. Admin B's UPDATE runs at 2:00:21 PM, overwriting the entire row with their in-memory values. The row now has status=Processing, address=123 Main St, payment=Card ending 5555.
  3. Admin A's address change is gone. Nobody knows until a customer complains about the wrong address on their receipt.

This happens because neither write was atomic with respect to the read. The last writer always wins, even if they didn't see the first writer's changes. In a busy admin dashboard or a high-concurrency API, this pattern repeats constantly and silently.

Optimistic Concurrency with Concurrency Tokens

Entity Framework Core solves this with optimistic concurrency control. The idea is simple: attach a version token to every row. When you update, EF includes that token in the WHERE clause. If the token has changed since you read it, your update affects zero rows, and EF throws DbUpdateConcurrencyException.

The most common approach is the [Timestamp] attribute, which maps to a rowversion (SQL Server) or xmin (PostgreSQL) column. Here's how to set it up:

csharp
public class Order
{
    public int Id { get; set; }
    public string OrderNumber { get; set; }
    public string Status { get; set; }
    public string ShippingAddress { get; set; }
    public string PaymentMethod { get; set; }
    
    [Timestamp]
    public byte[] RowVersion { get; set; } = [];
}

In your DbContext, enable concurrency checking on the model:

csharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>()
        .Property(o => o.RowVersion)
        .IsRowVersion();
}

Now when you update an order, EF generates SQL like this:

csharp
UPDATE Orders
SET Status = 'Shipped', ShippingAddress = '456 Oak Ave', RowVersion = DEFAULT
WHERE Id = 42 AND RowVersion = @originalRowVersion

If Admin A and Admin B both load order 42 at 2:00:10 PM, they both get the same RowVersion value (let's say 0x00000000000000001). When Admin A updates at 2:00:20 PM, the database increments RowVersion to 0x00000000000000002. Now Admin B's update at 2:00:21 PM tries to SET a new value WHERE RowVersion = 0x00000000000000001. That condition is no longer true. Zero rows affected. Exception thrown. Admin B knows to reload and see what changed.

Catching and Handling Conflicts

When a concurrency conflict occurs, EF Core raises DbUpdateConcurrencyException. The right place to catch this is in your API endpoint or application service. Here's a practical pattern:

csharp
public async Task<IActionResult> UpdateOrder(int orderId, UpdateOrderRequest request)
{
    try
    {
        var order = await _context.Orders.FindAsync(orderId);
        if (order == null)
            return NotFound();

        order.Status = request.Status;
        order.ShippingAddress = request.ShippingAddress;
        order.PaymentMethod = request.PaymentMethod;

        await _context.SaveChangesAsync();
        return Ok(order);
    }
    catch (DbUpdateConcurrencyException ex)
    {
        // Someone else changed the order since we read it
        return Conflict(new
        {
            message = "This order was modified by another user. Please reload and try again.",
            currentValues = ex.Entries.First().CurrentValues.ToObject()
        });
    }
}

Returning HTTP 409 Conflict signals the client to refresh the order from the server and let the user decide whether to retry, merge changes, or abandon their edit. This is far better than silently losing data.

In more complex scenarios, you might want to merge changes automatically. For example, if Admin A changed the address and Admin B changed the payment method, both edits could coexist. You'd reload the current state from the database, reapply only the fields the user changed, and retry:

csharp
catch (DbUpdateConcurrencyException ex)
{
    var entry = ex.Entries.First();
    var databaseValues = await entry.GetDatabaseValuesAsync();
    
    // Reload the current row version from database
    entry.OriginalValues.SetValues(databaseValues);
    
    // Reapply only the user's changes (simplified example)
    entry.CurrentValues["ShippingAddress"] = request.ShippingAddress;
    
    // Retry the save
    await _context.SaveChangesAsync();
    return Ok("Changes merged successfully");
}

This approach works well if your updates are sparse (users typically change a small number of fields) and conflicts are rare. If conflicts are frequent, it signals a UX problem—users are stepping on each other's toes, and you should consider optimistic locking at the UI level (disabling certain fields if they're being edited by someone else) or using a different architecture like CQRS with event sourcing.

PostgreSQL Tip: Free Concurrency with xmin

If you're using PostgreSQL and Npgsql, you can skip the explicit RowVersion column entirely. PostgreSQL's xmin system column serves the exact same purpose and is maintained automatically by the database for every row.

Configure it in your model like this:

csharp
public class Order
{
    public int Id { get; set; }
    public string OrderNumber { get; set; }
    public string Status { get; set; }
    public string ShippingAddress { get; set; }
    
    [Timestamp]
    public uint xmin { get; set; }
}

Then in OnModelCreating, tell Npgsql to use it:

csharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>()
        .Property(o => o.xmin)
        .HasColumnName("xmin")
        .IsRowVersion();
}

No migration needed. No extra storage. PostgreSQL tracks row versions for MVCC (Multi-Version Concurrency Control) anyway, so you're just exposing what's already there. This is a hidden gem if you're on PostgreSQL.

Common Mistakes

  • Forgetting to include the concurrency token in SELECT: If you use context.Orders.FromSqlInterpolated() or a raw query, make sure the concurrency token is in the result set. EF can't detect conflicts if it doesn't know the current token value.
  • Catching DbUpdateConcurrencyException but not returning 409: If you catch the exception and silently retry, you might loop forever or apply stale updates. Always signal the client so they can reload.
  • Using pessimistic locking (row locks) as your first choice: Pessimistic locking blocks readers while a writer updates. It's slower and can cause deadlocks. Use optimistic concurrency first unless you truly have unresolvable conflicts.
  • Not testing concurrency scenarios: Write a unit test that simulates two DbContext instances reading the same row, modifying it independently, and saving. This catches issues before production.
  • Assuming timestamps are monotonically increasing: SQL Server's rowversion is globally unique but not necessarily meaningful. Don't treat it as a timestamp for business logic. Use it only for conflict detection.

Key Takeaways

  • The lost update problem occurs when two concurrent edits happen without version control. The last write silently overwrites earlier changes.
  • Add [Timestamp] to your entity to enable optimistic concurrency. EF will include the token in UPDATE WHERE clauses automatically.
  • Catch DbUpdateConcurrencyException in your service or controller, return 409 Conflict, and let the client reload the fresh state.
  • On PostgreSQL, use xmin instead of an explicit RowVersion column. It's free and built in.
  • Decide whether to reject conflicts (return 409), merge changes automatically, or redesign the UI to prevent them in the first place.
  • Test concurrency scenarios in your test suite. Concurrency bugs are silent until production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles