Almost every production application applies the same predicates to every query: exclude soft-deleted rows, scope results to the current tenant, hide unpublished records. Writing those predicates in every LINQ expression is repetitive and dangerous; miss one and you have a data leak or a cross-tenant exposure. EF Core's Global Query Filters let you register those predicates once on the entity type and have EF Core apply them automatically to every query that touches that type.
By the end of this post you will have a working DbContext that enforces both soft-delete and tenant-scoping filters automatically, supports injecting the tenant identity from the current HTTP request, and exposes clean per-query escape hatches via IgnoreQueryFilters().
Before starting, make sure you have:
- .NET 8 SDK or later
- EF Core 8 (
Microsoft.EntityFrameworkCoreplus a provider:Npgsql.EntityFrameworkCore.PostgreSQLorMicrosoft.EntityFrameworkCore.SqlServer) - A working
DbContextwith at least one entity mapped to a table - Familiarity with fluent configuration inside
OnModelCreating
A Global Query Filter is a LINQ predicate registered against an entity type in OnModelCreating. EF Core appends it to every LINQ query that targets that entity, including queries reached through navigation properties. The filter is composed with any additional predicates the caller adds, so nothing about the behavior changes from the caller's perspective.
Registering a filter
The entire API surface is a single call on the entity builder:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Article>()
.HasQueryFilter(a => !a.IsDeleted);
}
That one line means every dbContext.Articles query now contains WHERE IsDeleted = 0 in the generated SQL without any caller needing to know about it. Only one filter is allowed per entity type; if you need to combine conditions, combine them in a single lambda.
Soft delete stores a flag on the row instead of issuing a DELETE statement. Combined with a Global Query Filter, flagged rows become completely invisible to normal application queries. The data is preserved for auditing or restoration, and no call site needs to remember to filter it out.
The shared interface
Define an interface so any entity can opt in without repeating the same two properties:
public interface ISoftDelete
{
bool IsDeleted { get; set; }
DateTimeOffset? DeletedAt { get; set; }
}
public class Article : ISoftDelete
{
public int Id { get; set; }
public Guid TenantId { get; set; }
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}
Registering the filter for all soft-delete entities at once
Rather than calling HasQueryFilter on every entity individually, iterate the model and use System.Linq.Expressions to build the predicate dynamically. This approach scales to any number of entities with no per-entity boilerplate:
using System.Linq.Expressions;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (!typeof(ISoftDelete).IsAssignableFrom(entityType.ClrType))
continue;
var parameter = Expression.Parameter(entityType.ClrType, "e");
var property = Expression.Property(parameter, nameof(ISoftDelete.IsDeleted));
var falseValue = Expression.Constant(false);
var body = Expression.Equal(property, falseValue);
var lambda = Expression.Lambda(body, parameter);
modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
}
}
Performing a soft delete
A generic helper method keeps the pattern consistent across repositories:
public async Task SoftDeleteAsync<T>(
T entity,
CancellationToken ct = default)
where T : class, ISoftDelete
{
entity.IsDeleted = true;
entity.DeletedAt = DateTimeOffset.UtcNow;
await _context.SaveChangesAsync(ct);
}
The entity stays in the database. Every subsequent query on that type simply never returns it because the filter hides it.
A tenant filter follows the same pattern as soft delete, but it needs to read the current tenant identifier at query time rather than at model-building time. Because the filter lambda can close over a service instance, EF Core re-reads the tenant value on every query, which means the filter stays correct across the lifetime of a scoped DbContext.
The tenant provider
public interface ITenantProvider
{
Guid TenantId { get; }
}
public class HttpContextTenantProvider : ITenantProvider
{
private readonly IHttpContextAccessor _accessor;
public HttpContextTenantProvider(IHttpContextAccessor accessor)
=> _accessor = accessor;
public Guid TenantId
{
get
{
var raw = _accessor.HttpContext?
.User.FindFirstValue("tenant_id");
return Guid.TryParse(raw, out var id) ? id : Guid.Empty;
}
}
}
Wiring the provider into DbContext
public class AppDbContext : DbContext
{
private readonly ITenantProvider _tenantProvider;
public AppDbContext(
DbContextOptions<AppDbContext> options,
ITenantProvider tenantProvider)
: base(options)
{
_tenantProvider = tenantProvider;
}
public DbSet<Article> Articles => Set<Article>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Article>()
.HasQueryFilter(a =>
a.TenantId == _tenantProvider.TenantId
&& !a.IsDeleted);
}
}
The lambda closes over _tenantProvider, the provider object itself, not a captured Guid value. EF Core evaluates the expression tree and emits _tenantProvider.TenantId as a SQL parameter, so the query plan is cache-friendly and the correct tenant value is read on every call.
Guid variable captured at DbContext construction time. EF Core would treat it as a constant baked into the query, and all requests would silently query the same tenant regardless of who is calling.
Registering the services
// Program.cs
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantProvider, HttpContextTenantProvider>();
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
Some operations legitimately need unfiltered access: an admin panel that lists all tenants, a background cleanup job that hard-deletes soft-deleted rows older than 90 days, or a restore operation that retrieves a specific deleted record. Use IgnoreQueryFilters() to opt out on a per-query basis.
// Retrieve a soft-deleted article by ID for an admin restore
var deleted = await _context.Articles
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.Id == id && a.IsDeleted, ct);
// Super-admin: list all articles across all tenants
var all = await _context.Articles
.IgnoreQueryFilters()
.OrderByDescending(a => a.Id)
.ToListAsync(ct);
// Cleanup job: hard-delete rows soft-deleted more than 90 days ago
var cutoff = DateTimeOffset.UtcNow.AddDays(-90);
await _context.Articles
.IgnoreQueryFilters()
.Where(a => a.IsDeleted && a.DeletedAt < cutoff)
.ExecuteDeleteAsync(ct);
IgnoreQueryFilters() removes all registered filters for that query. If you need to bypass only the tenant filter but keep the soft-delete filter, the cleanest option is to replicate the soft-delete predicate inline in that specific query. Selective filter bypassing per-filter is not supported natively but is on the EF Core backlog.
Global Query Filters let you encode cross-cutting data rules, soft delete, tenant scoping, publication gating, directly into the model. Every query that touches the entity inherits the filter automatically, and bypassing it is a deliberate, visible opt-out at the call site.
Next in this series: EF Core Relationships Explained, covering one-to-many, many-to-many with and without payload, and owned entities as value objects.
No comments: