Articles › EF Core & SQL

Transactions and Concurrency in EF Core: Optimistic Locking, Isolation and Race Conditions

Keep data consistent under load: how SaveChanges uses transactions, explicit transactions, execution strategies, optimistic concurrency with RowVersion, isolation levels and safe counters.

Two users buy the last item in stock at the same moment. Two admins edit the same article and one silently overwrites the other. A transfer debits one account but crashes before crediting the other. These are consistency problems, and they appear only under real concurrency — which is why they slip through testing. This guide shows how EF Core and SQL Server help you prevent them.

Transactions: all or nothing

A transaction groups operations so they either all succeed or all fail (atomicity), and other users don't see half-finished work (isolation).

SaveChanges is already transactional

var order = new Order { CustomerId = 7 };
order.Items.Add(new OrderItem { ProductId = 3, Quantity = 2 });
db.Orders.Add(order);

product.Stock -= 2;

await db.SaveChangesAsync();   // INSERT order, INSERT items, UPDATE product — one transaction

If any statement fails, everything rolls back. For most use cases, one SaveChanges per business operation is all you need.

Explicit transactions

Use an explicit transaction when one operation spans several SaveChanges calls, raw SQL or bulk operations:

await using var tx = await db.Database.BeginTransactionAsync(ct);

var from = await db.Accounts.SingleAsync(a => a.Id == fromId, ct);
var to   = await db.Accounts.SingleAsync(a => a.Id == toId, ct);

from.Balance -= amount;
to.Balance   += amount;
await db.SaveChangesAsync(ct);

await db.Transfers.Where(t => t.Status == TransferStatus.Pending && t.AccountId == fromId)
    .ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TransferStatus.Done), ct);

await tx.CommitAsync(ct);   // without Commit, disposing the transaction rolls back

Keep transactions short: never call external APIs, send emails or wait for user input inside one. Long transactions hold locks and block other requests.

Transactions with retry strategies

With EnableRetryOnFailure (common for cloud databases), wrap the whole unit in an execution strategy so a transient failure retries the entire transaction:

var strategy = db.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(async () =>
{
    await using var tx = await db.Database.BeginTransactionAsync(ct);
    // ... work
    await db.SaveChangesAsync(ct);
    await tx.CommitAsync(ct);
});

Concurrency conflicts: the lost update problem

10:00:00  Admin A loads article #5 (Title = "Old")
10:00:05  Admin B loads article #5 (Title = "Old")
10:00:30  A saves Title = "Version A"
10:00:40  B saves Excerpt = "New excerpt"  → also writes Title = "Old" back!

B's save silently overwrote A's change. This is the lost update problem.

Optimistic concurrency with RowVersion

Optimistic concurrency assumes conflicts are rare, doesn't lock anything, but detects conflicts at save time. In SQL Server, a rowversion column changes automatically on every update:

public class Article
{
    public int Id { get; set; }
    public string Title { get; set; } = "";

    [Timestamp]                          // maps to SQL Server rowversion
    public byte[] RowVersion { get; set; } = [];
}

EF Core now adds the version to the WHERE clause:

UPDATE Articles SET Title = @p0
WHERE Id = @p1 AND RowVersion = @p2;

If someone else changed the row, zero rows match and EF Core throws DbUpdateConcurrencyException:

try
{
    await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException ex)
{
    var entry = ex.Entries.Single();
    var databaseValues = await entry.GetDatabaseValuesAsync(ct);

    if (databaseValues is null)
        return NotFound("The article was deleted by another user.");

    // Option 1: tell the client to reload and retry (most common for UIs)
    return Conflict("This article was modified by someone else. Please reload.");

    // Option 2: "client wins" — refresh the original values and save again
    // entry.OriginalValues.SetValues(databaseValues);
    // await db.SaveChangesAsync(ct);
}

Round-tripping the version through an API

For web APIs, send the version to the client and require it back:

public record ArticleDto(int Id, string Title, string Version);        // Base64 rowversion
public record UpdateArticle(string Title, string Version);

var article = await db.Articles.FindAsync([id], ct);
db.Entry(article!).Property(a => a.RowVersion).OriginalValue = Convert.FromBase64String(cmd.Version);
article!.Title = cmd.Title;
await db.SaveChangesAsync(ct);   // throws if the version is stale

This maps naturally to HTTP: the version can be an ETag, sent back in If-Match, and a conflict becomes 412 Precondition Failed or 409 Conflict.

Race conditions on counters and stock

Read-modify-write in application code is unsafe under concurrency:

// ❌ Two requests read Stock = 1, both subtract 1, both save → stock = 0, but two items sold
var product = await db.Products.FindAsync(id);
if (product.Stock >= qty) { product.Stock -= qty; await db.SaveChangesAsync(); }

Let the database do it atomically:

// ✅ Single atomic statement with a guard condition
var updated = await db.Products
    .Where(p => p.Id == id && p.Stock >= qty)
    .ExecuteUpdateAsync(s => s.SetProperty(p => p.Stock, p => p.Stock - qty), ct);

if (updated == 0)
    return Conflict("Not enough stock.");

The same pattern works for view counters, likes and quotas. Alternatively, mark Stock as a concurrency token (.IsConcurrencyToken()) and retry on conflict.

Unique constraints beat "check then insert"

// ❌ race: two requests both see "not exists" and both insert
if (!await db.Users.AnyAsync(u => u.Email == email)) db.Users.Add(newUser);

A unique index makes the database enforce it; catch the violation and return a friendly error. Validation gives a nice message in the common case; the constraint guarantees correctness in the rare race.

Isolation levels

Isolation controls what concurrent transactions can see:

Level Prevents Notes
Read Uncommitted nothing dirty reads — avoid
Read Committed dirty reads SQL Server default
Repeatable Read non-repeatable reads holds shared locks longer
Serializable phantoms strongest, most blocking
Snapshot / RCSI readers don't block writers uses row versioning in tempdb

Many teams enable Read Committed Snapshot Isolation (RCSI) on SQL Server so reads never block writes:

ALTER DATABASE Portfolio SET READ_COMMITTED_SNAPSHOT ON;

Choose stronger levels only for specific operations that need them:

await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);

Pessimistic locking (rarely needed)

When conflicts are frequent and retries are expensive, you can lock rows explicitly with raw SQL hints (WITH (UPDLOCK, ROWLOCK)) inside a transaction. Use this sparingly — it reduces throughput and can cause deadlocks.

Checklist

  • One SaveChanges per business operation where possible
  • Explicit, short transactions for multi-step operations
  • Execution strategy wrapping transactions when retries are enabled
  • RowVersion on entities edited by multiple users
  • Atomic ExecuteUpdate for counters and stock
  • Unique constraints for uniqueness rules
  • Consider RCSI to reduce blocking

Summary

SaveChanges is already atomic; add explicit transactions only when a business operation needs several steps, and keep them short. Prevent lost updates with optimistic concurrency (RowVersion + DbUpdateConcurrencyException), make counters and stock changes atomic in SQL, rely on unique constraints instead of "check then insert", and choose isolation levels deliberately. These patterns keep your data correct when real traffic arrives.

FAQ

Optimistic or pessimistic concurrency?

Optimistic for most web apps — conflicts are rare and nothing is locked. Pessimistic only for hot rows with frequent conflicts.

Does EF Core use transactions for reads?

No explicit transaction is needed for queries; each query runs under the database's default isolation.

Can I use RowVersion with SQLite or PostgreSQL?

They don't have SQL Server's rowversion, but you can use a concurrency token property you update yourself (or PostgreSQL's xmin system column via the Npgsql provider).