EntityFrameworkCore.Locking.PostgreSQL
0.1.0
See the version list below for details.
dotnet add package EntityFrameworkCore.Locking.PostgreSQL --version 0.1.0
NuGet\Install-Package EntityFrameworkCore.Locking.PostgreSQL -Version 0.1.0
<PackageReference Include="EntityFrameworkCore.Locking.PostgreSQL" Version="0.1.0" />
<PackageVersion Include="EntityFrameworkCore.Locking.PostgreSQL" Version="0.1.0" />
<PackageReference Include="EntityFrameworkCore.Locking.PostgreSQL" />
paket add EntityFrameworkCore.Locking.PostgreSQL --version 0.1.0
#r "nuget: EntityFrameworkCore.Locking.PostgreSQL, 0.1.0"
#:package EntityFrameworkCore.Locking.PostgreSQL@0.1.0
#addin nuget:?package=EntityFrameworkCore.Locking.PostgreSQL&version=0.1.0
#tool nuget:?package=EntityFrameworkCore.Locking.PostgreSQL&version=0.1.0
EntityFrameworkCore.Locking
Pessimistic locking for EF Core. Supports PostgreSQL, MySQL, and SQL Server.
- Row-level locks —
ForUpdate()/ForShare()LINQ extension methods scoped to a transaction - Distributed locks —
AcquireDistributedLockAsync()session-scoped advisory locks, no transaction required
Installation
dotnet add package EntityFrameworkCore.Locking.PostgreSQL # Npgsql
dotnet add package EntityFrameworkCore.Locking.MySql # Pomelo
dotnet add package EntityFrameworkCore.Locking.SqlServer # Microsoft.Data.SqlClient
Setup
Call .UseLocking() after your provider's Use*() call:
// PostgreSQL
services.AddDbContext<AppDbContext>(o =>
o.UseNpgsql(connectionString)
.UseLocking());
// MySQL
services.AddDbContext<AppDbContext>(o =>
o.UseMySql(connectionString, serverVersion)
.UseLocking());
// SQL Server
services.AddDbContext<AppDbContext>(o =>
o.UseSqlServer(connectionString)
.UseLocking());
Usage
All locking queries require an active transaction.
await using var tx = await ctx.Database.BeginTransactionAsync();
// Basic exclusive lock (FOR UPDATE / WITH (UPDLOCK, HOLDLOCK, ROWLOCK))
var product = await ctx.Products
.Where(p => p.Id == id)
.ForUpdate()
.FirstOrDefaultAsync();
// Skip rows already locked by another transaction
var available = await ctx.Products
.Where(p => p.Status == "pending")
.ForUpdate(LockBehavior.SkipLocked)
.ToListAsync();
// Fail immediately if lock cannot be acquired
var row = await ctx.Products
.Where(p => p.Id == id)
.ForUpdate(LockBehavior.NoWait)
.FirstOrDefaultAsync();
// Wait up to 500ms for the lock
var row = await ctx.Products
.Where(p => p.Id == id)
.ForUpdate(LockBehavior.Wait, TimeSpan.FromMilliseconds(500))
.FirstOrDefaultAsync();
// Shared lock (PostgreSQL and MySQL only)
var row = await ctx.Products
.Where(p => p.Id == id)
.ForShare()
.FirstOrDefaultAsync();
await tx.CommitAsync();
PostgreSQL-only: ForNoKeyUpdate and ForKeyShare
These modes are available when using the EntityFrameworkCore.Locking.PostgreSQL package:
// FOR NO KEY UPDATE — blocks writers but allows FOR KEY SHARE (FK lookups)
var row = await ctx.Products
.Where(p => p.Id == id)
.ForNoKeyUpdate()
.FirstOrDefaultAsync();
// FOR KEY SHARE — minimal shared lock, only blocks FOR UPDATE
// Useful for FK-referencing queries that should not block non-key updates
var row = await ctx.Products
.Where(p => p.Id == id)
.ForKeyShare()
.FirstOrDefaultAsync();
Include with locking (PostgreSQL)
PostgreSQL automatically scopes the lock to the root table when a collection Include is present (emits FOR UPDATE OF "t"), so you can use Include directly without AsSplitQuery():
// Works — FOR UPDATE OF "p" is emitted automatically
var product = await ctx.Products
.Include(p => p.OrderLines)
.Where(p => p.Id == id)
.ForUpdate()
.FirstOrDefaultAsync();
Queue processing pattern
A common use of ForUpdate(LockBehavior.SkipLocked) is a worker queue where multiple consumers race to claim items:
await using var tx = await ctx.Database.BeginTransactionAsync();
var item = await ctx.Jobs
.Where(j => j.Status == "pending")
.OrderBy(j => j.CreatedAt)
.ForUpdate(LockBehavior.SkipLocked)
.FirstOrDefaultAsync();
if (item is null)
return; // all items claimed by other workers
item.Status = "processing";
await ctx.SaveChangesAsync();
await tx.CommitAsync();
Distributed locks
Distributed (advisory) locks let you coordinate across processes without tying the lock to a database row or transaction. They are session-scoped — the lock is held until you dispose the handle, or until the connection drops.
No transaction is required.
// Acquire — blocks until available (optional timeout)
await using var handle = await ctx.AcquireDistributedLockAsync("invoice:generate");
// ... critical section ...
// lock released automatically on dispose
// With a timeout — throws LockTimeoutException if not acquired within 5 s
await using var handle = await ctx.AcquireDistributedLockAsync(
"report:daily", TimeSpan.FromSeconds(5));
// With ASP.NET request cancellation
await using var handle = await ctx.AcquireDistributedLockAsync(
"report:daily", timeout: null, cancellationToken: ct);
// TryAcquire — returns null immediately if already held
var handle = await ctx.TryAcquireDistributedLockAsync("invoice:generate");
if (handle is null)
return Results.Conflict("Another process is generating the invoice.");
await using (handle) { /* critical section */ }
// Synchronous variants are also available
using var handle = ctx.AcquireDistributedLock("report:daily");
var handle = ctx.TryAcquireDistributedLock("report:daily");
// Check support at runtime
if (ctx.SupportsDistributedLocks()) { ... }
Lock keys
Keys are plain strings, up to 255 characters. The library handles provider-specific encoding internally:
- PostgreSQL — hashed to a
bigintvia XxHash32 with a namespace prefix ("EFLK"); the hash is computed in-process so no extra round-trip is needed. - MySQL — passed as-is for keys ≤ 64 UTF-8 bytes; longer keys are SHA-256 hashed to
lock:<hex58>(64 chars). Thelock:prefix is reserved. - SQL Server — passed as-is (max 255 chars, enforced upstream).
Provider-specific behavior
| Feature | PostgreSQL | MySQL | SQL Server |
|---|---|---|---|
| Native primitive | pg_advisory_lock |
GET_LOCK |
sp_getapplock @LockOwner='Session' |
| Timeout | SET LOCAL lock_timeout (ms) |
GET_LOCK(@key, seconds) — rounded up to 1 s |
@LockTimeout ms |
| Cancellation | Driver-level (best-effort) | KILL QUERY side-channel |
Attention signal |
MySQL timeout precision: GET_LOCK timeout is in whole seconds. Sub-second timeouts are rounded up to 1 second.
Cancellation caveat: advisory lock SQL is a blocking database call. Cancellation sends a cancel signal to the driver; if the driver does not honor it before the timeout fires, the call completes via timeout. Always combine a timeout with the CancellationToken for bounded waits.
Exception handling
try
{
await using var handle = await ctx.AcquireDistributedLockAsync(
"report:daily", TimeSpan.FromSeconds(5));
}
catch (LockTimeoutException)
{
// Not acquired within the timeout
}
catch (LockAlreadyHeldException ex)
{
// Same DbContext + connection attempted to acquire the same key twice
// ex.Key contains the key name
}
catch (LockingConfigurationException)
{
// Provider does not support distributed locks, or UseLocking() was not called
}
LockAlreadyHeldException is thrown synchronously before any database call when the same (DbContext, connection, key) triple is already registered. Acquiring the same key from two different DbContext instances on different connections will block (or return null for TryAcquire) as expected.
Lock modes and behaviors
| Method | Generated SQL |
|---|---|
ForUpdate() |
FOR UPDATE / WITH (UPDLOCK, HOLDLOCK, ROWLOCK) |
ForUpdate(LockBehavior.NoWait) |
FOR UPDATE NOWAIT / SET LOCK_TIMEOUT 0 |
ForUpdate(LockBehavior.SkipLocked) |
FOR UPDATE SKIP LOCKED (PG/MySQL) / WITH (UPDLOCK, ROWLOCK, READPAST) (SQL Server) |
ForUpdate(LockBehavior.Wait, timeout) |
SET LOCAL lock_timeout = '500ms' (PG) / SET SESSION innodb_lock_wait_timeout (MySQL) / SET LOCK_TIMEOUT 500 (SQL Server) |
ForShare() |
FOR SHARE (PostgreSQL/MySQL only) |
ForNoKeyUpdate() |
FOR NO KEY UPDATE (PostgreSQL only) |
ForKeyShare() |
FOR KEY SHARE (PostgreSQL only) |
Exception handling
Lock failures throw typed exceptions from EntityFrameworkCore.Locking.Exceptions:
try
{
var row = await ctx.Products
.Where(p => p.Id == id)
.ForUpdate(LockBehavior.NoWait)
.FirstOrDefaultAsync();
}
catch (LockTimeoutException ex)
{
// Lock could not be acquired (NOWAIT or timeout exceeded)
}
catch (DeadlockException ex)
{
// Deadlock detected — retry the transaction
}
catch (LockingConfigurationException ex)
{
// Programmer error: missing transaction, unsupported query shape,
// or unsupported lock mode for this provider
}
Exception hierarchy:
LockingException(base)LockAcquisitionFailedExceptionLockTimeoutException— timeout or NOWAIT failureDeadlockException— deadlock victimLockAlreadyHeldException— same key acquired twice on the same connection (distributed locks)
LockingConfigurationException— programmer error (missing transaction, unsupported query shape, provider not configured)
Provider limitations
| Feature | PostgreSQL | MySQL | SQL Server |
|---|---|---|---|
ForUpdate |
✓ | ✓ | ✓ |
ForShare |
✓ | ✓ | ✗ |
ForNoKeyUpdate |
✓ | ✗ | ✗ |
ForKeyShare |
✓ | ✗ | ✗ |
SkipLocked |
✓ | ✓ | ✓ (via READPAST) |
NoWait |
✓ | ✓ | ✓ |
| Wait with timeout | ✓ (ms) | ✓ (ceil to 1s) | ✓ (ms) |
ForNoKeyUpdate and ForKeyShare are PostgreSQL-only extension methods available when the EntityFrameworkCore.Locking.PostgreSQL package is installed. Using ForShare on SQL Server throws LockingConfigurationException.
SQL Server SkipLocked limitation: SQL Server uses WITH (UPDLOCK, ROWLOCK, READPAST) instead of SKIP LOCKED. READPAST only skips rows held under row-level or page-level locks — rows under a table-level lock are blocked rather than skipped. For typical queue-processing workloads this behaves identically to SKIP LOCKED on PostgreSQL/MySQL.
MySQL timeout precision: MySQL's innodb_lock_wait_timeout is in whole seconds. Sub-second timeouts are rounded up to 1 second.
Unsupported query shapes
UNION, EXCEPT, INTERSECT with locking throw LockingConfigurationException at query execution time. Use per-query locks on individual queries before combining results.
AsSplitQuery() combined with locking throws LockingConfigurationException — use regular Include() instead (on PostgreSQL, FOR UPDATE OF is emitted automatically to handle outer joins).
Supported database versions
| Database | Minimum version | Notes |
|---|---|---|
| PostgreSQL | 14 | Default minimum for Npgsql 8.x. PG 12+ works if you call .SetPostgresVersion(12, 0) in UseNpgsql. All locking features (FOR NO KEY UPDATE, FOR KEY SHARE, SKIP LOCKED, NOWAIT) have been available since PG 9.3/9.5. |
| MySQL | 8.0 | FOR SHARE, SKIP LOCKED, and NOWAIT were introduced in MySQL 8.0.1. MySQL 5.7 is not supported. |
| MariaDB | 10.6 | SKIP LOCKED requires 10.6+. NOWAIT requires 10.3+. ForShare emits LOCK IN SHARE MODE (MariaDB does not support the FOR SHARE syntax). |
| SQL Server | 2019 | All hints (UPDLOCK, HOLDLOCK, ROWLOCK, READPAST) and SET LOCK_TIMEOUT are available on all supported versions. Azure SQL Database is also supported. |
Target frameworks
net8.0, net9.0, net10.0
License
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- EntityFrameworkCore.Locking (>= 0.1.0)
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 8.0.0)
- System.IO.Hashing (>= 8.0.0)
-
net8.0
- EntityFrameworkCore.Locking (>= 0.1.0)
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 8.0.0)
- System.IO.Hashing (>= 8.0.0)
-
net9.0
- EntityFrameworkCore.Locking (>= 0.1.0)
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 8.0.0)
- System.IO.Hashing (>= 8.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.