A Clever Way to Implement Pessimistic Locking in EF Core
Learn pessimistic locking in EF Core using UPDLOCK and FOR UPDATE with a simple analogy, diagrams, and a clean reusable helper. Stop race conditions on shared rows.
A bank passbook at the village counter
Imagine a small bank in your town. There is only one paper passbook for an account, kept at the counter.
Two people walk in at the same time to update the same account. If both grab the passbook together and write new balances, the numbers get messed up. One person's entry erases the other's.
So the clerk uses a simple rule. When you come to update the passbook, the clerk hands it to you and to nobody else. Everyone else has to wait in line. When you finish and return the book, the next person gets it.
That waiting rule is pessimistic locking. We assume a clash will happen, so we stop it before it starts by giving the row to one person at a time.
The opposite idea, optimistic locking, is more relaxed. It lets everyone read and write, and only at the end it checks "did someone change this while I was busy?" If yes, it asks you to try again.
This article is about the pessimistic style, and a clean, reusable way to do it in EF Core.
Why we even need this
Let us look at a real bug. Say you sell concert tickets. Only 1 ticket is left. Two buyers click "Buy" at the exact same moment.
Without any lock, both requests do this:
- Read the row: stock is
1. Good, looks available. - Subtract one:
1 - 1 = 0. - Save.
Both think they got the last ticket. You just sold the same seat twice. This is called a race condition.
The race condition without a lock
Steps
Buyer A reads
stock = 1
Buyer B reads
stock = 1 too
Both sell
each subtracts 1
Result
sold twice, oversold
A pessimistic lock fixes this. Buyer A locks the row first. Buyer B has to wait. By the time B reads, stock is already 0, so B is correctly told "sold out".
The two flavours of concurrency control
Before we code, here is a quick side-by-side so you pick the right tool.
| Feature | Optimistic locking | Pessimistic locking |
|---|---|---|
| When it checks for conflict | At save time | Up front, before reading |
| How it works | Version / RowVersion column | Real database row lock |
| Others can read the row | Yes, freely | They wait (blocked) |
| Best when | Conflicts are rare | Many users fight over same row |
| Cost | Retry on conflict | Waiting and possible deadlocks |
| Needs explicit transaction | Not always | Always |
Neither one is "better". They solve the same problem from opposite directions. Use optimistic when clashes are rare (editing your own profile). Use pessimistic when the same row is hot (last ticket, wallet balance, shared counter).
The core idea in EF Core
Here is the key fact: EF Core has no built-in pessimistic lock method. There is no .Lock() you can call.
But the database already knows how to lock rows. EF Core lets you send raw SQL, and SQL has lock hints. So the trick is simple:
- Start an explicit transaction.
- Read the row with a raw SQL query that includes a lock hint.
- The lock is now held for the whole transaction.
- Do your work (change the entity).
- Save changes and commit. The lock is released.
The lock hint differs by database:
| Database | Lock hint | Notes |
|---|---|---|
| SQL Server | WITH (UPDLOCK, ROWLOCK) | UPDLOCK blocks other writers; ROWLOCK keeps it to one row |
| PostgreSQL | FOR UPDATE | Can add NOWAIT or SKIP LOCKED |
| MySQL | FOR UPDATE | Same family as PostgreSQL |
Step 1: the raw SQL read with a lock
Let us lock a single ticket row on SQL Server. We use FromSql so EF Core still tracks the entity, which means we can change it and save normally.
using var transaction = await dbContext.Database
.BeginTransactionAsync(cancellationToken);
// UPDLOCK + ROWLOCK locks just this row until the transaction ends.
Ticket? ticket = await dbContext.Tickets
.FromSql(
$"""
SELECT * FROM Tickets WITH (UPDLOCK, ROWLOCK)
WHERE Id = {ticketId}
""")
.FirstOrDefaultAsync(cancellationToken);
if (ticket is null)
{
return Results.NotFound();
}
// From here, no other transaction can change this row.
ticket.Status = TicketStatus.Sold;
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);A few important points:
FromSql(with an interpolated string) is safe from SQL injection. EF Core turns{ticketId}into a real SQL parameter, not glued text.- Because we read through
DbSet<Ticket>, the entity is tracked. SoSaveChangesAsyncwrites our change. - The lock holds from the
FromSqlline untilCommitAsync. Keep that gap short.
Step 2: the PostgreSQL version
On PostgreSQL the same idea uses FOR UPDATE. You can also choose how to behave when the row is already locked.
using var transaction = await dbContext.Database
.BeginTransactionAsync(cancellationToken);
// FOR UPDATE locks the row. NOWAIT fails fast instead of waiting.
Ticket? ticket = await dbContext.Tickets
.FromSql(
$"""
SELECT * FROM "Tickets"
WHERE "Id" = {ticketId}
FOR UPDATE NOWAIT
""")
.FirstOrDefaultAsync(cancellationToken);
if (ticket is null)
{
return Results.NotFound();
}
ticket.Status = TicketStatus.Sold;
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);The choice of waiting style matters a lot:
| Option | What it does | Good for |
|---|---|---|
FOR UPDATE | Wait until the row is free | Normal cases, you must process this row |
FOR UPDATE NOWAIT | Fail instantly if locked | Fast APIs that should not hang |
FOR UPDATE SKIP LOCKED | Skip locked rows, grab the next free one | Job queues, worker pools |
SKIP LOCKED is the secret behind many job-queue systems. Ten workers can each grab a different free job without stepping on each other.
Step 3: the clever, reusable helper
Writing that raw SQL everywhere is messy and easy to get wrong. The clever part is to hide it behind one small extension method. Then your business code stays clean and the database detail lives in one place.
public static class LockingExtensions
{
// One place that knows the lock SQL. Callers just ask for a locked row.
public static Task<TEntity?> FirstWithLockAsync<TEntity>(
this DbSet<TEntity> dbSet,
int id,
string tableName,
CancellationToken ct = default)
where TEntity : class
{
// SQL Server flavour. Swap the hint for FOR UPDATE on PostgreSQL.
FormattableString sql =
$"SELECT * FROM {tableName} WITH (UPDLOCK, ROWLOCK) WHERE Id = {id}";
return dbSet.FromSql(sql).FirstOrDefaultAsync(ct);
}
}Now the buying code reads almost like plain English:
using var transaction = await dbContext.Database
.BeginTransactionAsync(ct);
Ticket? ticket = await dbContext.Tickets
.FirstWithLockAsync(ticketId, "Tickets", ct);
if (ticket is null || ticket.Status == TicketStatus.Sold)
{
await transaction.RollbackAsync(ct);
return Results.Conflict("Ticket not available.");
}
ticket.Status = TicketStatus.Sold;
await dbContext.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);The "clever" idea is not magic SQL. It is putting the ugly part in one tested helper so the rest of your app never has to think about lock hints again.
Clean layering with the helper
Steps
Business code
asks for locked row
Helper
adds UPDLOCK / FOR UPDATE
Database
locks the row
What is really happening under the hood
When you ask for UPDLOCK or FOR UPDATE, the database places a lock record on that row. Any other transaction that tries to take a conflicting lock on the same row is put to sleep. The database wakes it up only when your transaction commits or rolls back.
This is why two rules matter so much:
- Always use a transaction. A lock with no transaction has nothing to attach to and gets released too soon (or never the way you expect).
- Keep the locked section short. While you hold the lock, everyone else who wants that row is waiting. Do not call slow web APIs or send emails while holding a lock.
Watch out for deadlocks
A deadlock is when two transactions wait on each other and neither can move.
Picture two clerks. Clerk A holds passbook 1 and now wants passbook 2. Clerk B holds passbook 2 and now wants passbook 1. Both wait forever. The bank manager (the database) notices, picks a loser, and throws one out.
The cure is calm and simple:
- Lock rows in a fixed order. If everyone always locks the lower Id first, the circle can never form.
- Keep transactions short. Less time holding locks means less chance to overlap.
- Use
NOWAITorSKIP LOCKEDwhere waiting is not worth it, so a request fails fast instead of hanging. - Handle the failure. Catch the deadlock or timeout error, roll back, and either retry or tell the user politely.
When NOT to use pessimistic locking
Pessimistic locking is powerful, but it is not free. Avoid it when:
- Conflicts are rare. Optimistic locking with a
RowVersioncolumn is lighter. - You hold the lock for a long time. Long locks create long queues and angry users.
- The work spans many rows or services. Then a queue or a distributed lock may serve you better.
A good habit: reach for optimistic locking first. Move to pessimistic only when the same row is genuinely hot and retries become painful, such as a flash sale on one product.
A note on libraries and licensing
You do not need any paid library for this. Pessimistic locking here uses only EF Core itself plus your database. That is worth saying because some popular .NET libraries changed their terms recently. For example, MediatR and MassTransit moved to commercial licensing for newer versions. None of that touches this technique. Plain EF Core FromSql inside a transaction is all you need, and it is fully open and free.
Putting it all together
Here is the mental checklist for a safe pessimistic lock:
- Begin an explicit transaction.
- Read the target row with
FromSqland a lock hint. - Check the row is still valid (not sold, still in stock).
- Make your change on the tracked entity.
SaveChangesAsync, thenCommitAsync.- On any error,
RollbackAsyncso the lock is freed.
Follow those six steps and you turn a dangerous race condition into a calm, orderly line, just like the village clerk handing out one passbook at a time.
Quick recap
- Pessimistic locking means "lock the row first, let others wait." It stops race conditions on hot rows.
- EF Core has no built-in lock API. You add a lock hint through
FromSqlinside an explicit transaction. - Use
WITH (UPDLOCK, ROWLOCK)on SQL Server andFOR UPDATEon PostgreSQL or MySQL. FromSqlwith an interpolated string is parameterised and safe from SQL injection, and keeps the entity tracked so you can save.- The "clever" part is wrapping the SQL in one reusable helper so business code stays clean.
- The lock is tied to the transaction. Commit or roll back to release it, and keep that window short.
- Watch for deadlocks: lock in a fixed order, keep it short, and consider
NOWAITorSKIP LOCKED. - Prefer optimistic locking first; switch to pessimistic only when a single row is truly contested.
References and further reading
- Handling Concurrency Conflicts — EF Core (Microsoft Learn)
- A Clever Way To Implement Pessimistic Locking in EF Core — Milan Jovanović
- Solving Race Conditions With EF Core Optimistic Locking — Milan Jovanović
- Support SELECT FOR UPDATE / UPDLOCK — dotnet/efcore Issue #26042
- Optimistic vs. Pessimistic Concurrency in EF Core (with Table Hints) — DEV Community
Related Posts
EF Core Query Splitting: Fix Slow Queries and Cartesian Explosion
Learn how EF Core query splitting (AsSplitQuery) fixes the cartesian explosion problem with simple examples, diagrams, and real performance numbers. Know when to split and when not to.
Soft Delete with EF Core: Delete Data Without Losing It
Learn soft delete in EF Core the right way. Use an interceptor and global query filters to hide deleted rows automatically, with simple examples, diagrams, code, and best practices for .NET 10.
Complete Guide to Transaction Isolation Levels in SQL
Learn SQL transaction isolation levels the easy way: dirty reads, non-repeatable reads, phantoms, snapshot, and serializable with simple diagrams and C# code.
Introduction to Locking and Concurrency Control in .NET 6
A beginner-friendly guide to locking and concurrency control in .NET 6 and EF Core, with a simple analogy, diagrams, code, and optimistic vs pessimistic locking.
How to Create Migrations for Multiple Databases in EF Core (.NET 10)
Learn how to create EF Core migrations for multiple databases like SQL Server, SQLite, and PostgreSQL using separate migration projects, with simple examples.
Get Started with SQL Transactions in PostgreSQL
Learn SQL transactions in PostgreSQL the easy way: BEGIN, COMMIT, ROLLBACK, ACID, savepoints, and isolation levels with simple diagrams and C# examples.