EntityFrameworkCore.Locking.PostgreSQL 0.1.0

There is a newer version of this package available.
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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="EntityFrameworkCore.Locking.PostgreSQL" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="EntityFrameworkCore.Locking.PostgreSQL" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="EntityFrameworkCore.Locking.PostgreSQL" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add EntityFrameworkCore.Locking.PostgreSQL --version 0.1.0
                    
#r "nuget: EntityFrameworkCore.Locking.PostgreSQL, 0.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package EntityFrameworkCore.Locking.PostgreSQL@0.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=EntityFrameworkCore.Locking.PostgreSQL&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=EntityFrameworkCore.Locking.PostgreSQL&version=0.1.0
                    
Install as a Cake Tool

EntityFrameworkCore.Locking

Pessimistic locking for EF Core. Supports PostgreSQL, MySQL, and SQL Server.

  • Row-level locksForUpdate() / ForShare() LINQ extension methods scoped to a transaction
  • Distributed locksAcquireDistributedLockAsync() 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 bigint via 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). The lock: 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)
    • LockAcquisitionFailedException
      • LockTimeoutException — timeout or NOWAIT failure
      • DeadlockException — deadlock victim
      • LockAlreadyHeldException — 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

MIT

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.4.1 167 5/14/2026
0.4.0 202 4/25/2026
0.3.0 95 4/25/2026
0.2.0 97 4/25/2026
0.1.1 99 4/19/2026
0.1.0 94 4/19/2026