You can build a crash-safe distributed lock in .NET on Postgres advisory locks, with no new infrastructure. A session-level advisory lock serializes work across every instance that shares the database, and it auto-releases the moment the holding connection closes, so a dead process can never wedge a lock. If your instances already share a Postgres database, they already share a lock manager.
I needed a distributed lock for an unglamorous reason: awarding achievements. On Katabench, two concurrent submissions from the same user could both observe "streak not yet awarded" and both award it. A classic check-then-act race, per user, across API instances, so an in-process SemaphoreSlim lock cannot fix it once the API scales past one instance.
The reflex answer is Redis and a RedLock library (I surveyed the options in distributed locking in .NET). But Postgres is already in the architecture, and it has a built-in lock manager.
Advisory Locks in One Minute
Postgres advisory locks are locks over an application-defined 64-bit key. Postgres never interprets the key; it just guarantees that only one session can hold it at a time:
SELECT pg_advisory_lock(42); -- blocks until acquired
SELECT pg_advisory_unlock(42); -- explicit release
Two properties make the session-level variant the right primitive for a distributed lock:
- It serializes across every instance that shares the database. The lock is held in Postgres, not in process memory.
- It auto-releases when the holding connection closes. When a pod crashes or a deploy kills it, the OS closes its connections and Postgres frees the lock immediately. A network partition or a lost node never closes the connection cleanly, so Postgres frees the lock only once TCP keepalives detect the dead peer (over two hours with default Linux keepalive settings). There is no lease to renew.
A TTL-based Redis lease has to pick an expiry: too short and the lock expires mid-critical-section (now you need fencing tokens), too long and a crash wedges the key for the full TTL. Session advisory locks sidestep the dilemma, because the lock's lifetime is the connection's lifetime.
The Implementation
The full class is small:
public sealed class PostgresAdvisoryLock(
string connectionString,
ILogger<PostgresAdvisoryLock> logger) : IDistributedLock
{
// How long a caller waits for a contended lock before giving up.
private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(15);
public async Task<IAsyncDisposable> AcquireAsync(
string key,
CancellationToken cancellationToken = default)
{
long lockKey = HashKey(key);
NpgsqlConnection? connection = null;
try
{
// A dedicated connection: the lock is held for the handle's lifetime.
connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var timeout = CancellationTokenSource
.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(WaitTimeout);
using var command = new NpgsqlCommand(
"SELECT pg_advisory_lock(@key)", connection);
command.Parameters.AddWithValue("key", lockKey);
await command.ExecuteNonQueryAsync(timeout.Token);
return new Handle(connection, lockKey, logger);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Our WaitTimeout fired, not the caller's token: a holder is stuck.
logger.LogWarning(
"Lock '{Key}' timed out after {Timeout}s; proceeding without it.",
key, WaitTimeout.TotalSeconds);
await DisposeQuietlyAsync(connection);
return NullAsyncDisposable.Instance;
}
catch (Exception ex) when (ex is NpgsqlException or TimeoutException)
{
logger.LogWarning(ex, "Lock '{Key}' unavailable; proceeding without it.", key);
await DisposeQuietlyAsync(connection);
return NullAsyncDisposable.Instance;
}
}
}
The lock is tied to a dedicated connection.
pg_advisory_lock binds the lock to the session that ran it, so the handle owns one connection from acquire to dispose.
Each held lock pins a connection for the duration of the critical section.
Keep critical sections short, or you will run into an exhausted connection pool.
The wait is bounded.
pg_advisory_lock blocks server-side until the lock is granted, so a stuck holder could hang requests forever.
The linked cancellation token caps the wait at 15 seconds, and Npgsql cancels the server-side wait when it fires.
String keys are hashed with FNV-1a, not GetHashCode.
Advisory locks take a bigint, and the map from "achievements:user:123" to that bigint must be identical in every process:
public static long HashKey(string key)
{
const ulong offsetBasis = 14695981039346656037UL;
const ulong prime = 1099511628211UL;
ulong hash = offsetBasis;
foreach (byte b in Encoding.UTF8.GetBytes(key))
{
hash ^= b;
hash *= prime;
}
return unchecked((long)hash);
}
string.GetHashCode() is randomized per process in .NET, so two instances would hash the same key to different lock ids and never contend.
A 64-bit FNV-1a collision, if you ever hit one, only makes two unrelated keys occasionally serialize: a performance blip, never a correctness bug.
Release has a built-in safety net.
Disposal runs pg_advisory_unlock, and even if that command fails, Postgres releases every advisory lock a session holds when the session ends.
With default pooling, disposing the connection returns the session to the pool instead of ending it, so a lock left by a failed unlock stays held until Npgsql resets the connection with DISCARD ALL on its next use, or the pool closes it as idle.
Add Pooling=false to the lock's connection string if you want disposal to end the session immediately.
Failing Open Is a Choice: Make It Consciously
Notice what the catch blocks do: when the lock cannot be acquired (database unreachable, or a stuck holder times out the wait), the method logs a warning and returns a no-op handle, and the caller proceeds without the lock.
That is fail-open, and it is only correct because of what this lock protects: a best-effort feature where the fallback is the pre-lock behavior (a rare duplicate award, resolved by an idempotent write). Degrading to the old race beats turning submissions into 500s because the lock store hiccuped.
Invert the decision the moment the lock guards something that must never run twice: money movement, external side effects, destructive migrations.
There, a failed acquisition should fail the operation loudly.
Fail-open versus fail-closed is a per-call-site decision about blast radius.
The class above hardcodes fail-open, so give AcquireAsync an option to rethrow instead of returning the no-op handle when a call site needs fail-closed.
When Is Postgres the Wrong Lock?
Reach for something else in these cases:
- High lock throughput. A connection open per acquisition is fine at "per-user, occasionally contended" rates and wrong at thousands of acquisitions per second. That is Redis territory.
- No shared database. If the services that need mutual exclusion do not already share a Postgres instance, adding one just for locks buys you nothing over Redis.
- Critical section = exactly one transaction. Use
pg_advisory_xact_lockinstead: it releases automatically at commit or rollback, and there is no handle to dispose.
Summary
If your instances already share a Postgres database, and your contention profile is "rare and brief", you get a crash-safe distributed lock with zero new infrastructure and about a hundred lines of code.
Frequently Asked Questions
What are PostgreSQL advisory locks?
Application-defined locks managed by Postgres but not tied to any table or row. Your application picks a 64-bit key and calls pg_advisory_lock(key); Postgres serializes all sessions that request the same key. Postgres never interprets the key; it only guarantees mutual exclusion, so you can use advisory locks as a distributed lock for any work, not only database rows.
What happens to an advisory lock if the application crashes?
Session-level advisory locks are released automatically when the backing connection closes, and a crashed process closes its connections. A dead process can never wedge a lock key, but a lost node or a network partition delays the release until TCP keepalives detect the dead connection. This is the property that TTL-based locks in Redis have to approximate with expiry times and fencing tokens.
What is the difference between session-level and transaction-level advisory locks?
pg_advisory_lock is held until explicitly unlocked or the session ends, so it can span multiple transactions and protect work that is not database-bound. pg_advisory_xact_lock is released automatically at the end of the current transaction and cannot be unlocked early. Use the transaction-level variant when the critical section is exactly one transaction; use session-level when it is not.
Are Postgres advisory locks better than Redis distributed locks?
If your instances already share a Postgres database and lock contention is low, yes: you add zero infrastructure and get crash-safe auto-release for free. Redis-based locks make sense at high lock throughput, where opening a Postgres connection per acquisition would be too expensive, or when there is no shared database in the architecture.



