.NET Programming With Me

Handling Concurrency Conflicts in EF Core with PostgreSQL

Optimistic concurrency assumes conflicts are rare but makes them visible when they happen. Without it, the last write silently wins: two users edit the same record, the first save succeeds, the second save overwrites it with stale data, and no one knows. EF Core's concurrency support turns that silent overwrite into an explicit exception you can handle on your terms.

PostgreSQL makes this particularly clean because every row carries a built-in system column called xmin that changes on every update. By the end of this post you will have EF Core using xmin as a concurrency token, a typed exception handler that covers all three resolution strategies (client wins, database wins, and custom merge), and a tested endpoint that demonstrates the full conflict cycle.


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK
  • EF Core 8 with the Npgsql provider: Npgsql.EntityFrameworkCore.PostgreSQL version 8.x
  • A running PostgreSQL instance (local or Docker)
  • A working DbContext with at least one entity you intend to protect
Note: If you are on SQL Server instead of PostgreSQL, replace UseXminAsConcurrencyToken() with a [Timestamp] attribute or .IsRowVersion() in fluent config. Everything from section 3 onward applies identically.

2 Understanding the concurrency problem

Optimistic concurrency works by recording the version of a row when it is loaded and then asserting that version has not changed when the update is applied. The SQL EF Core generates looks like this conceptually:

UPDATE Products
SET    Price = @newPrice,
       UpdatedAt = @now
WHERE  Id = @id
AND    xmin = @originalXmin;  -- the version we loaded

If another process updated the row between our read and our write, xmin will have advanced and the WHERE clause will match zero rows. EF Core checks the affected row count: zero rows updated means a conflict, and it throws DbUpdateConcurrencyException.

What is xmin? Every PostgreSQL row stores the transaction ID of the last transaction that inserted or updated it in a hidden system column called xmin. It is monotonically increasing per-row and requires no application-level migration to use.

3 Configuring the concurrency token

The Npgsql EF Core provider exposes a single fluent call that maps xmin as the concurrency token. Add it to your entity configuration and add a uint property to the entity class to hold the shadow value.

Entity class

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int StockQuantity { get; set; }
    public DateTimeOffset UpdatedAt { get; set; }

    // Maps to PostgreSQL's xmin system column
    public uint Version { get; set; }
}

Fluent configuration in OnModelCreating

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>(product =>
    {
        product.HasKey(p => p.Id);

        product.Property(p => p.Price)
            .HasColumnType("numeric(18,4)")
            .IsRequired();

        product.Property(p => p.Version)
            .IsRowVersion()
            .HasColumnName("xmin")
            .HasColumnType("xid");

        product.UseXminAsConcurrencyToken();
    });
}

The combination of UseXminAsConcurrencyToken() and mapping the Version property ensures EF Core reads xmin after every query and includes it in the WHERE clause of every UPDATE. You do not need to set or manage Version yourself; PostgreSQL owns it.

Migrations note: xmin is a system column; it does not appear in your migration's CreateTable call. If you generate a migration after adding UseXminAsConcurrencyToken(), confirm that the migration contains no column additions for xmin or Version. If it does, remove them manually before applying.

4 Catching and handling DbUpdateConcurrencyException

When a concurrency conflict occurs, EF Core throws DbUpdateConcurrencyException. The exception carries an Entries collection: one EntityEntry per entity involved in the failed save. Use the entry to inspect proposed values, original values, and the current database state, then apply one of three resolution strategies.

The resolution helper

public enum ConcurrencyResolution
{
    ClientWins,
    DatabaseWins,
    Merge
}

public async Task<bool> TrySaveWithRetryAsync(
    ConcurrencyResolution resolution,
    Func<EntityEntry, Task>? mergeStrategy = null,
    CancellationToken ct = default)
{
    const int maxRetries = 3;
    for (int attempt = 1; attempt <= maxRetries; attempt++)
    {
        try
        {
            await _context.SaveChangesAsync(ct);
            return true;
        }
        catch (DbUpdateConcurrencyException ex)
        {
            if (attempt == maxRetries) throw;

            foreach (var entry in ex.Entries)
            {
                var dbValues = await entry.GetDatabaseValuesAsync(ct);

                if (dbValues is null)
                    throw new InvalidOperationException(
                        $"Row for {entry.Metadata.Name} was deleted concurrently.");

                switch (resolution)
                {
                    case ConcurrencyResolution.ClientWins:
                        // Tell EF the "original" version is now the DB version,
                        // so the next save sees no conflict.
                        entry.OriginalValues.SetValues(dbValues);
                        break;

                    case ConcurrencyResolution.DatabaseWins:
                        // Overwrite proposed values with what is in the database.
                        entry.CurrentValues.SetValues(dbValues);
                        entry.OriginalValues.SetValues(dbValues);
                        break;

                    case ConcurrencyResolution.Merge:
                        if (mergeStrategy is not null)
                            await mergeStrategy(entry);
                        entry.OriginalValues.SetValues(dbValues);
                        break;
                }
            }
        }
    }
    return false;
}

GetDatabaseValuesAsync issues a fresh SELECT to fetch the current row state from the database. It returns null when the row was deleted between your read and your write, which you should treat as a non-retriable error.


5 Applying the strategies in a real endpoint

Here is a complete update endpoint that demonstrates all three strategies. In a real application you would choose one strategy per entity type based on business rules, not expose all three to the caller.

Client wins: the user's changes always prevail

public async Task<IResult> UpdateProductPriceAsync(
    int id, decimal newPrice, uint clientVersion, CancellationToken ct)
{
    var product = await _context.Products.FindAsync([id], ct);
    if (product is null) return Results.NotFound();

    product.Price     = newPrice;
    product.UpdatedAt = DateTimeOffset.UtcNow;

    try
    {
        await _context.SaveChangesAsync(ct);
        return Results.NoContent();
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var entry     = ex.Entries.Single();
        var dbValues  = await entry.GetDatabaseValuesAsync(ct)
            ?? throw new InvalidOperationException("Product was deleted.");

        // Update the original token so the next attempt succeeds
        entry.OriginalValues.SetValues(dbValues);
        await _context.SaveChangesAsync(ct);
        return Results.NoContent();
    }
}

Database wins: preserve what is already committed

catch (DbUpdateConcurrencyException ex)
{
    var entry    = ex.Entries.Single();
    var dbValues = await entry.GetDatabaseValuesAsync(ct)
        ?? throw new InvalidOperationException("Product was deleted.");

    // Discard the user's proposed values entirely
    entry.CurrentValues.SetValues(dbValues);
    entry.OriginalValues.SetValues(dbValues);

    // No SaveChanges call needed; nothing to write
    return Results.Conflict(new { message = "Your changes were overridden by a concurrent update." });
}

Custom merge: combine fields from both versions

catch (DbUpdateConcurrencyException ex)
{
    var entry    = ex.Entries.Single();
    var dbValues = await entry.GetDatabaseValuesAsync(ct)
        ?? throw new InvalidOperationException("Product was deleted.");

    var dbProduct       = (Product)dbValues.ToObject();
    var proposedProduct = (Product)entry.Entity;

    // Business rule: user owns the price; the database owns the stock count
    proposedProduct.StockQuantity = dbProduct.StockQuantity;
    proposedProduct.UpdatedAt     = DateTimeOffset.UtcNow;

    // Refresh the concurrency token so the retry succeeds
    entry.OriginalValues.SetValues(dbValues);
    await _context.SaveChangesAsync(ct);
    return Results.NoContent();
}

Wrapping up

Optimistic concurrency with xmin in PostgreSQL is one of the lowest-friction concurrency control options available: no application-managed columns, no migration schema changes, and no locks. EF Core's DbUpdateConcurrencyException gives you everything you need to implement client wins, database wins, or a custom field-level merge in a small, testable helper method.

Next in this series: Raw SQL, FromSqlRaw, and Dapper side-by-side in a .NET 8 API, covering when to step outside the LINQ query builder and which tool to reach for when you do.

Got a question or ran into a problem? Drop a comment below and I will reply.

No comments: