PostgreSQL-specific extensions for Entity Framework Core: row-level locking (FOR UPDATE with Wait/SkipLocked/NoWait), random-increment sequence ID generation, natural sort keys, and schema rollback helpers.
$ dotnet add package Pandatech.EFCore.PostgresExtensionsPostgreSQL-specific extensions for Entity Framework Core that fill the gaps left by the official Npgsql provider.
| Feature | What it does |
|---|---|
| Row-level locking | FOR UPDATE with Wait, SkipLocked, and NoWait behaviors |
| Random-increment IDs | Non-predictable sequential IDs backed by a PostgreSQL sequence |
| Natural sort keys | Human-friendly ordering for strings containing numbers |
| Schema rollback helpers | Clean Down() migration methods for all of the above |
Targets net8.0, net9.0, and net10.0.
dotnet add package Pandatech.EFCore.PostgresExtensions
PostgreSQL's FOR UPDATE clause lets you lock selected rows for the duration of a transaction. This package exposes it
as a LINQ extension method with three lock behaviors.
Register the query interceptor on your DbContext:
services.AddDbContextPool<AppDbContext>(options =>
options.UseNpgsql(connectionString)
.UseQueryLocks());
The ForUpdate method must be called inside a transaction:
await using var transaction = await dbContext.Database.BeginTransactionAsync();
var order = await dbContext.Orders
.Where(o => o.Id == orderId)
.ForUpdate(LockBehavior.SkipLocked)
.FirstOrDefaultAsync();
// Modify the locked row
order.Status = OrderStatus.Processing;
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
| Behavior | SQL generated | When to use |
|---|---|---|
Default (Wait) | FOR UPDATE | You need the row and can wait for it |
SkipLocked | FOR UPDATE SKIP LOCKED | Queue-style processing — skip rows another worker already holds |
NoWait | FOR UPDATE NOWAIT | Fail immediately if the row is locked |
Generates bigint IDs that increment by a random amount within a configurable range. The IDs are unique and always
increasing, but the gaps between them are unpredictable — preventing enumeration attacks while keeping an index-friendly
insert order.
public class Animal
{
public long Id { get; set; }
public string Name { get; set; }
}
public class AnimalConfiguration : IEntityTypeConfiguration<Animal>
{
public void Configure(EntityTypeBuilder<Animal> builder)
{
builder.HasKey(x => x.Id);
builder.Property(x => x.Id)
.HasRandomIdSequence();
}
}
The function must be created before the table that references it. Add it manually at the top of your Up() method:
protected override void Up(MigrationBuilder migrationBuilder)
{
// Create the sequence + function first
migrationBuilder.CreateRandomIdSequence(
tableName: "animal",
pkName: "id",
startValue: 5,
minRandIncrementValue: 5,
maxRandIncrementValue: 10);
// Then create the table (the default value references the function)
migrationBuilder.CreateTable(...);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropRandomIdSequence("animal", "id");
migrationBuilder.DropTable("animal");
}
Parameters: startValue is the first ID returned, minRandIncrementValue and maxRandIncrementValue define the random
gap range between consecutive IDs.
Sorts strings that contain numbers the way a human would expect: "Item 2" before "Item 10", not after it. The
package creates a PostgreSQL function that zero-pads numeric substrings, producing a sortable text key.
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateNaturalSortKeyFunction();
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropNaturalSortKeyFunction();
}
public class Building
{
public long Id { get; set; }
public string Address { get; set; }
public string AddressNaturalSortKey { get; set; }
}
public class BuildingConfiguration : IEntityTypeConfiguration<Building>
{
public void Configure(EntityTypeBuilder<Building> builder)
{
builder.Property(x => x.AddressNaturalSortKey)
.HasNaturalSortKey("address");
}
}
Then order by the computed column:
var sorted = await dbContext.Buildings
.OrderBy(b => b.AddressNaturalSortKey)
.ToListAsync();
For columns that store encrypted data where only the first 64 characters are deterministic (e.g., hash prefix), you can create a unique index on that prefix:
// In migration Up()
migrationBuilder.CreateUniqueIndexOnEncryptedColumn("users", "email_encrypted");
// With an optional WHERE condition
migrationBuilder.CreateUniqueIndexOnEncryptedColumn("users", "email_encrypted", "is_active = true");
// In migration Down()
migrationBuilder.DropUniqueIndexOnEncryptedColumn("users", "email_encrypted");
| Method | Target | Description |
|---|---|---|
UseQueryLocks() | DbContextOptionsBuilder | Registers the interceptor that rewrites tagged queries into FOR UPDATE SQL |
ForUpdate() | IQueryable<T> | Tags a query for row-level locking (must be inside a transaction) |
HasRandomIdSequence() | PropertyBuilder | Configures the property to use a random-increment sequence as its default value |
HasNaturalSortKey(column) | PropertyBuilder | Configures the property as a stored computed column using the natural sort function |
CreateRandomIdSequence(...) | MigrationBuilder | Creates the PostgreSQL sequence and generator function |
DropRandomIdSequence(...) | MigrationBuilder | Drops the sequence and generator function |
CreateNaturalSortKeyFunction() | MigrationBuilder | Creates the natural sort key function |
DropNaturalSortKeyFunction() | MigrationBuilder | Drops the natural sort key function |
CreateUniqueIndexOnEncryptedColumn(...) | MigrationBuilder | Creates a unique index on the first 64 chars of a column |
DropUniqueIndexOnEncryptedColumn(...) | MigrationBuilder | Drops the encrypted-column unique index |
LockBehavior: Default (wait), SkipLocked, NoWait.
MIT