Named Query Filters (EF Core 10)
Combine soft-delete and tenant filters on one entity with EF Core 10 named filters, and switch off one of them without leaking the other.
b.Entity<Order>()
.HasQueryFilter("SoftDelete", o => !o.IsDeleted)
.HasQueryFilter("Tenant", o => o.TenantId == TenantId);
db.Orders.IgnoreQueryFilters(["SoftDelete"])A global query filter is a predicate EF Core adds to every query for an entity type: LINQ queries, navigations loaded with Include, FromSql results, Find, and the WHERE clause of ExecuteUpdate and ExecuteDelete. The two classic uses are soft delete and tenant isolation, and most multi-tenant systems need both on the same entity.
Why names matter
Before EF Core 10 an entity had one filter. A second HasQueryFilter call silently replaced the first, so teams combined everything into one lambda with &&. Then the "recycle bin" endpoint called IgnoreQueryFilters() to show deleted orders, which removed the tenant condition too, and every tenant's deleted orders appeared. EF Core 10 lets each filter have a name: HasQueryFilter("SoftDelete", ...) and HasQueryFilter("Tenant", ...). IgnoreQueryFilters(["SoftDelete"]) switches off only that one; IgnoreQueryFilters() with no argument still removes all of them. Name every filter on an entity: EF Core rejects an entity that mixes an anonymous filter with named ones when it builds the model, so migrate an existing anonymous filter to a name in the same change.
| Query | Filters applied | Who should use it |
|---|---|---|
db.Orders | SoftDelete and Tenant | Every normal endpoint |
IgnoreQueryFilters(["SoftDelete"]) | Tenant only | Recycle bin, restore, audit views |
IgnoreQueryFilters() | None | Platform jobs that are cross-tenant by design |
Per-request values
The model is built once per context type and cached for the life of the process. A filter that refers to a property or field of the DbContext, such as o.TenantId == TenantId, is re-evaluated for each context instance and sent as a parameter. A filter that captures a local variable or a constant in OnModelCreating is baked into the cached model: every request gets the tenant of the first one. Inject a tenant accessor into the context and expose it through a property. With AddDbContextPool the constructor cannot take scoped services, so set the property after leasing the context, from a scoped factory.
Surprises in production
- Required navigations. If
Customeris soft-deleted andOrder.Customeris required,Include(o => o.Customer)uses an INNER JOIN and the orders of deleted customers vanish. EF warns about filtered principals on required relationships; make the navigation optional or filter both sides the same way. - Unique indexes. A soft-deleted product still owns its SKU, so recreating it violates the unique index. Use a filtered index:
HasIndex(p => p.Sku).IsUnique().HasFilter("\"IsDeleted\" = false"). - Only EF sees them. Dapper,
SqlQueryover unmapped types, reporting tools and a psql session ignore the filters. For tenancy, add a second barrier such as PostgreSQL row-level security. - Indexes. Every query now filters on
TenantIdandIsDeleted; putTenantIdfirst in composite indexes.
Example
using System.Security.Claims;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantContext, ClaimsTenantContext>();
builder.Services.AddDbContext<ShopDb>(o => o.UseNpgsql(builder.Configuration.GetConnectionString("Shop")));
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
var app = builder.Build();
var orders = app.MapGroup("/orders").RequireAuthorization();
// Both filters: this tenant's live orders.
orders.MapGet("/", async (ShopDb db, CancellationToken ct) =>
TypedResults.Ok(await db.Orders.AsNoTracking()
.OrderByDescending(o => o.Id).Take(50)
.Select(o => new OrderRow(o.Id, o.Total, o.IsDeleted))
.ToListAsync(ct)));
// Recycle bin: soft delete off, tenant isolation still on.
orders.MapGet("/deleted", async (ShopDb db, CancellationToken ct) =>
TypedResults.Ok(await db.Orders.IgnoreQueryFilters([ShopDb.SoftDeleteFilter]).AsNoTracking()
.Where(o => o.IsDeleted)
.Select(o => new OrderRow(o.Id, o.Total, o.IsDeleted))
.ToListAsync(ct)));
orders.MapPost("/{id:int}/restore", async Task<Results<NoContent, NotFound>> (int id, ShopDb db, CancellationToken ct) =>
{
var restored = await db.Orders.IgnoreQueryFilters([ShopDb.SoftDeleteFilter])
.Where(o => o.Id == id && o.IsDeleted)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.IsDeleted, false), ct);
return restored == 1 ? TypedResults.NoContent() : TypedResults.NotFound();
});
app.Run();
public record OrderRow(int Id, decimal Total, bool IsDeleted);
public interface ITenantContext
{
Guid TenantId { get; }
}
public sealed class ClaimsTenantContext(IHttpContextAccessor accessor) : ITenantContext
{
public Guid TenantId =>
Guid.TryParse(accessor.HttpContext?.User.FindFirstValue("tenant_id"), out var id)
? id
: throw new InvalidOperationException("The request has no tenant_id claim.");
}
public class Order
{
public int Id { get; set; }
public Guid TenantId { get; set; }
public decimal Total { get; set; }
public bool IsDeleted { get; set; }
}
public class ShopDb(DbContextOptions<ShopDb> options, ITenantContext tenant) : DbContext(options)
{
public const string SoftDeleteFilter = "SoftDelete";
public const string TenantFilter = "Tenant";
// Read per query from this instance, so each request is filtered by its own tenant.
public Guid TenantId => tenant.TenantId;
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(order =>
{
order.HasQueryFilter(SoftDeleteFilter, o => !o.IsDeleted);
order.HasQueryFilter(TenantFilter, o => o.TenantId == TenantId);
order.HasIndex(o => new { o.TenantId, o.IsDeleted });
});
}
}When to use it
- Show a tenant's recycle bin of deleted orders with IgnoreQueryFilters(["SoftDelete"]) while the tenant filter stays in force.
- Let a support user recreate a product whose SKU belonged to a soft-deleted product, using a filtered unique index.
- Run a nightly platform job that counts orders across all tenants with IgnoreQueryFilters(), kept out of any HTTP endpoint.
More examples
Soft delete with an interceptor and a filtered unique index
Register it with <code>options.AddInterceptors(new SoftDeleteInterceptor(TimeProvider.System))</code>. Callers keep using <code>db.Products.Remove(product)</code> and the interceptor turns it into an UPDATE. Two limits: <code>ExecuteDeleteAsync</code> skips interceptors and really deletes, and EF's cascade handling still sees the original Deleted state for dependents tracked in the same context, so soft-delete children explicitly. The partial index uses PostgreSQL syntax; on SQL Server the filter is <code>[IsDeleted] = 0</code>.
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
public interface ISoftDeletable
{
bool IsDeleted { get; set; }
DateTimeOffset? DeletedAt { get; set; }
}
public class Product : ISoftDeletable
{
public int Id { get; set; }
public string Sku { get; set; } = "";
public string Name { get; set; } = "";
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}
public sealed class SoftDeleteInterceptor(TimeProvider clock) : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
ConvertDeletes(eventData.Context);
return result;
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
ConvertDeletes(eventData.Context);
return ValueTask.FromResult(result);
}
private void ConvertDeletes(DbContext? context)
{
if (context is null) return;
foreach (var entry in context.ChangeTracker.Entries<ISoftDeletable>().Where(e => e.State == EntityState.Deleted))
{
// Remove() becomes an UPDATE that hides the row.
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = clock.GetUtcNow();
}
}
}
public class CatalogDb(DbContextOptions<CatalogDb> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(product =>
{
product.HasQueryFilter("SoftDelete", p => !p.IsDeleted);
// Only live rows compete for the SKU, so a deleted product can be recreated.
product.HasIndex(p => p.Sku).IsUnique().HasFilter("\"IsDeleted\" = false");
});
}
}The captured-variable leak, and the fix
The two contexts differ by one local variable and behave completely differently. In the correct one the SQL contains a parameter (EF names it after the member, for example <code>@ef_filter__CurrentTenantId</code>) whose value is read from the context executing the query. In the leaky one the value is a constant inside the cached model. Integration tests that run requests for two tenants against the same host catch this; tests that build a fresh host per tenant do not.
using Microsoft.EntityFrameworkCore;
public class Invoice
{
public int Id { get; set; }
public Guid TenantId { get; set; }
public decimal Amount { get; set; }
}
public interface ITenantContext
{
Guid TenantId { get; }
}
// Wrong: OnModelCreating runs once per process. The first request's tenant is captured
// into the cached model and every later request, for every tenant, is filtered by it.
public class LeakyBillingDb(DbContextOptions<LeakyBillingDb> options, ITenantContext tenant) : DbContext(options)
{
public DbSet<Invoice> Invoices => Set<Invoice>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var tenantId = tenant.TenantId;
modelBuilder.Entity<Invoice>().HasQueryFilter("Tenant", i => i.TenantId == tenantId);
}
}
// Right: the filter reads a member of the context, which EF parameterises per instance.
public class BillingDb(DbContextOptions<BillingDb> options, ITenantContext tenant) : DbContext(options)
{
private Guid CurrentTenantId => tenant.TenantId;
public DbSet<Invoice> Invoices => Set<Invoice>();
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.Entity<Invoice>().HasQueryFilter("Tenant", i => i.TenantId == CurrentTenantId);
}A test that proves tenants stay isolated
The seed context has <code>TenantId</code> left at <code>Guid.Empty</code>, which is fine because inserts are not filtered. The second assertion is the one that matters: with only the soft-delete filter switched off, Acme sees its own deleted order but not Globex's. If someone replaces the named call with <code>IgnoreQueryFilters()</code> in the recycle-bin code, a test like this against the real endpoint goes red. SQLite is enough here because the filters are plain LINQ.
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Xunit;
public class Order
{
public int Id { get; set; }
public Guid TenantId { get; set; }
public bool IsDeleted { get; set; }
}
public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public Guid TenantId { get; set; }
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.Entity<Order>()
.HasQueryFilter("SoftDelete", o => !o.IsDeleted)
.HasQueryFilter("Tenant", o => o.TenantId == TenantId);
}
public class TenantFilterTests
{
private static readonly Guid Acme = Guid.Parse("0b6c9f4e-5d1a-4c2b-9a51-6f0e2d3c4b11");
private static readonly Guid Globex = Guid.Parse("7d2e8a10-3f4b-4e6c-8b9d-1a2b3c4d5e66");
[Fact]
public async Task Ignoring_soft_delete_keeps_the_tenant_filter()
{
var ct = TestContext.Current.CancellationToken;
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync(ct);
var options = new DbContextOptionsBuilder<ShopDb>().UseSqlite(connection).Options;
await using (var seed = new ShopDb(options))
{
await seed.Database.EnsureCreatedAsync(ct);
seed.Orders.AddRange(
new Order { TenantId = Acme },
new Order { TenantId = Acme, IsDeleted = true },
new Order { TenantId = Globex, IsDeleted = true });
await seed.SaveChangesAsync(ct);
}
await using var db = new ShopDb(options) { TenantId = Acme };
Assert.Equal(1, await db.Orders.CountAsync(ct));
Assert.Equal(2, await db.Orders.IgnoreQueryFilters(["SoftDelete"]).CountAsync(ct));
Assert.Equal(3, await db.Orders.IgnoreQueryFilters().CountAsync(ct));
}
}
Discussion