.NET Programming With Me

Global Query Filters in EF Core: Soft Delete, Multi-Tenancy, and More

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().


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK or later
  • EF Core 8 (Microsoft.EntityFrameworkCore plus a provider: Npgsql.EntityFrameworkCore.PostgreSQL or Microsoft.EntityFrameworkCore.SqlServer)
  • A working DbContext with at least one entity mapped to a table
  • Familiarity with fluent configuration inside OnModelCreating
Note: Global Query Filters were introduced in EF Core 2.0 and the API has not changed shape since. Everything in this post applies equally to EF Core 8 and EF Core 9.

2 How Global Query Filters work

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.

Important: A filter is registered on the entity type, not the table. If two entity types share a table via table splitting, register the filter on each entity type separately.

3 Soft delete: mark rows invisible without removing them

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.


4 Multi-tenancy: scope every query to the current tenant automatically

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.

Warning: Never close over a plain 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")));

5 Bypassing filters with IgnoreQueryFilters

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.


Wrapping up

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.

Got a question or ran into a problem? Drop a comment below and I will reply.

No comments: