//JorgenHoc
← All articles
EF CoreBy Jorge CalderónUpdated 19 min read

EF Core Global Query Filters — Soft Delete and Multi-Tenancy

Implement soft delete, multi-tenancy, and row-level security in EF Core using global query filters. Covers filter setup, bypassing, indexes, and testing.

#entity-framework#dotnet#database

Global query filters let you attach a WHERE clause to every LINQ query for a given entity type — once, in OnModelCreating, and never again in application code. They are the cleanest way to implement soft delete, multi-tenancy, and row-level security in EF Core without scattering .Where(x => !x.IsDeleted) calls across the entire codebase.

Every claim in this article is asserted by samples/ef-core-global-query-filters — a runnable console project where each line of output is a passing check, including two traps that writing the sample uncovered (see Verify It Yourself).

What Global Query Filters Are

EF Core applies a query filter as a predicate that gets automatically AND-combined with every query against that entity, including queries through navigation properties. If Post has a filter p => !p.IsDeleted, loading a Blog and including its Posts will only return non-deleted posts — even if you forgot to filter them explicitly.

Filters are registered per entity type in OnModelCreating:

modelBuilder.Entity<Post>()
    .HasQueryFilter(p => !p.IsDeleted);

You can bypass the filter for a specific query with IgnoreQueryFilters():

// Admin endpoint: see everything including soft-deleted
var allPosts = await db.Posts
    .IgnoreQueryFilters()
    .ToListAsync();
💡

Global query filters are applied at the SQL level, not in memory. EF Core translates the filter predicate into a SQL WHERE clause, so you never load rows you shouldn't see.

Implementing Soft Delete

The Entity Base Class

Define a shared base class for all soft-deletable entities. Putting the common fields here means the DbContext configuration stays DRY.

public abstract class SoftDeletableEntity
{
    public int Id { get; set; }
    public bool IsDeleted { get; set; }
    public DateTime? DeletedAt { get; set; }
    public string? DeletedBy { get; set; }
}
 
public abstract class AuditableEntity : SoftDeletableEntity
{
    public DateTime CreatedAt { get; set; }
    public DateTime UpdatedAt { get; set; }
    public string CreatedBy { get; set; } = string.Empty;
    public string UpdatedBy { get; set; } = string.Empty;
}

Concrete Entities

public class Blog : AuditableEntity
{
    public string Title { get; set; } = string.Empty;
    public string Slug { get; set; } = string.Empty;
    public ICollection<Post> Posts { get; set; } = new List<Post>();
}
 
public class Post : AuditableEntity
{
    public string Title { get; set; } = string.Empty;
    public string Body { get; set; } = string.Empty;
    public int BlogId { get; set; }
    public Blog Blog { get; set; } = null!;
    public ICollection<Tag> Tags { get; set; } = new List<Tag>();
}
 
public class Tag : AuditableEntity
{
    public string Name { get; set; } = string.Empty;
    public ICollection<Post> Posts { get; set; } = new List<Post>();
}

Registering the Filter in OnModelCreating

Use a loop over all entity types that inherit from SoftDeletableEntity so you never have to add the filter manually for new entities:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);
 
    // Apply soft-delete filter to every entity that inherits SoftDeletableEntity
    foreach (var entityType in modelBuilder.Model.GetEntityTypes())
    {
        if (!typeof(SoftDeletableEntity).IsAssignableFrom(entityType.ClrType))
            continue;
 
        // Build: e => !((SoftDeletableEntity)e).IsDeleted
        var param = Expression.Parameter(entityType.ClrType, "e");
        var prop = Expression.Property(
            Expression.Convert(param, typeof(SoftDeletableEntity)),
            nameof(SoftDeletableEntity.IsDeleted));
        var notDeleted = Expression.Not(prop);
        var lambda = Expression.Lambda(notDeleted, param);
 
        modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
    }
}
💡

The expression tree approach avoids writing HasQueryFilter for each entity individually. Every new entity that inherits SoftDeletableEntity automatically gets the filter.

Overriding SaveChanges for Soft Delete

Calling db.Posts.Remove(post) is fine — but override SaveChanges to intercept the deletion and convert it to an update. Override the (bool, CancellationToken) overload, not SaveChangesAsync(CancellationToken): all four public entry points (SaveChanges(), SaveChanges(bool), SaveChangesAsync(ct), SaveChangesAsync(bool, ct)) funnel through the bool overloads, so intercepting there covers synchronous callers too. Override only the ct overload and a plain db.SaveChanges() somewhere in the codebase quietly issues a real DELETE.

public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
    ApplySoftDeleteRules();
    return base.SaveChanges(acceptAllChangesOnSuccess);
}
 
public override Task<int> SaveChangesAsync(
    bool acceptAllChangesOnSuccess, CancellationToken ct = default)
{
    ApplySoftDeleteRules();
    return base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
}
 
private void ApplySoftDeleteRules()
{
    var now = DateTime.UtcNow;
 
    foreach (var entry in ChangeTracker.Entries<SoftDeletableEntity>())
    {
        switch (entry.State)
        {
            case EntityState.Deleted:
                // Convert hard delete to soft delete
                entry.State = EntityState.Modified;
                entry.Entity.IsDeleted = true;
                entry.Entity.DeletedAt = now;
                break;
 
            case EntityState.Added:
                // Ensure new entities are never accidentally marked deleted
                entry.Entity.IsDeleted = false;
                break;
        }
    }
 
    // Populate audit fields on AuditableEntity
    foreach (var entry in ChangeTracker.Entries<AuditableEntity>())
    {
        if (entry.State == EntityState.Added)
            entry.Entity.CreatedAt = now;
 
        if (entry.State is EntityState.Added or EntityState.Modified)
            entry.Entity.UpdatedAt = now;
    }
}
⚠️

ExecuteDelete/ExecuteDeleteAsync never enter SaveChanges at all — they translate straight to SQL DELETE and hard-delete rows right through this interception. In a soft-delete codebase, treat the ExecuteDelete family as superadmin-only, or express bulk soft-deletes as ExecuteUpdate(s => s.SetProperty(e => e.IsDeleted, true)) instead.

Restoring Soft-Deleted Records

Admin endpoints need to undelete records. Because the query filter hides deleted rows, you must use IgnoreQueryFilters() to find them first:

public async Task<Post?> RestorePostAsync(int postId)
{
    var post = await _db.Posts
        .IgnoreQueryFilters()
        .FirstOrDefaultAsync(p => p.Id == postId && p.IsDeleted);
 
    if (post is null)
        return null;
 
    post.IsDeleted = false;
    post.DeletedAt = null;
    post.DeletedBy = null;
 
    // Set Modified so SaveChanges sends an UPDATE (not intercepted as delete)
    _db.Entry(post).State = EntityState.Modified;
    await _db.SaveChangesAsync();
 
    return post;
}

Multi-Tenancy with Global Query Filters

For SaaS applications, every table that holds tenant data needs a TenantId column and a filter that restricts rows to the current tenant. The filter predicate must read the current tenant at query time, not at startup — so it must close over a scoped service, not a static value.

Tenant Resolution Service

public interface ITenantProvider
{
    Guid TenantId { get; }
}
 
public class HttpContextTenantProvider : ITenantProvider
{
    private readonly IHttpContextAccessor _httpContextAccessor;
 
    public HttpContextTenantProvider(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }
 
    public Guid TenantId
    {
        get
        {
            // Read from JWT claim, header, or subdomain — adjust to your auth strategy
            var claim = _httpContextAccessor.HttpContext?
                .User.FindFirst("tenant_id")?.Value;
 
            return Guid.TryParse(claim, out var id)
                ? id
                : throw new InvalidOperationException("TenantId claim missing.");
        }
    }
}

Multi-Tenant Entity Base

public abstract class TenantEntity : AuditableEntity
{
    public Guid TenantId { get; set; }
}
 
public class Invoice : TenantEntity
{
    public decimal Amount { get; set; }
    public string Currency { get; set; } = "USD";
    public DateTime IssuedAt { get; set; }
    public ICollection<InvoiceLineItem> LineItems { get; set; } = new List<InvoiceLineItem>();
}
 
public class InvoiceLineItem : TenantEntity
{
    public string Description { get; set; } = string.Empty;
    public decimal UnitPrice { get; set; }
    public int Quantity { get; set; }
    public int InvoiceId { get; set; }
    public Invoice Invoice { get; set; } = null!;
}

DbContext with Combined Filters

public class AppDbContext : DbContext
{
    private readonly ITenantProvider _tenantProvider;
 
    public AppDbContext(
        DbContextOptions<AppDbContext> options,
        ITenantProvider tenantProvider)
        : base(options)
    {
        _tenantProvider = tenantProvider;
    }
 
    public DbSet<Blog> Blogs => Set<Blog>();
    public DbSet<Post> Posts => Set<Post>();
    public DbSet<Tag> Tags => Set<Tag>();
    public DbSet<Invoice> Invoices => Set<Invoice>();
    public DbSet<InvoiceLineItem> InvoiceLineItems => Set<InvoiceLineItem>();
 
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
 
        ApplySoftDeleteFilters(modelBuilder);
        ApplyTenantFilters(modelBuilder);
 
        // Indexes for filter performance
        modelBuilder.Entity<Post>()
            .HasIndex(p => p.IsDeleted)
            .HasFilter("IsDeleted = 0"); // Partial index: only undeleted rows
 
        modelBuilder.Entity<Invoice>()
            .HasIndex(i => new { i.TenantId, i.IsDeleted });
 
        modelBuilder.Entity<InvoiceLineItem>()
            .HasIndex(li => new { li.TenantId, li.IsDeleted });
    }
 
    private static void ApplySoftDeleteFilters(ModelBuilder modelBuilder)
    {
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            if (!typeof(SoftDeletableEntity).IsAssignableFrom(entityType.ClrType))
                continue;
 
            var param = Expression.Parameter(entityType.ClrType, "e");
            var prop = Expression.Property(
                Expression.Convert(param, typeof(SoftDeletableEntity)),
                nameof(SoftDeletableEntity.IsDeleted));
            var filter = Expression.Lambda(Expression.Not(prop), param);
 
            modelBuilder.Entity(entityType.ClrType).HasQueryFilter(filter);
        }
    }
 
    private void ApplyTenantFilters(ModelBuilder modelBuilder)
    {
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            if (!typeof(TenantEntity).IsAssignableFrom(entityType.ClrType))
                continue;
 
            var param = Expression.Parameter(entityType.ClrType, "e");
 
            // Soft delete: !e.IsDeleted
            var isDeletedProp = Expression.Property(
                Expression.Convert(param, typeof(SoftDeletableEntity)),
                nameof(SoftDeletableEntity.IsDeleted));
            var notDeleted = Expression.Not(isDeletedProp);
 
            // Tenant: e.TenantId == _tenantProvider.TenantId
            //
            // The provider MUST be reached through the context instance
            // (Expression.Constant(this) -> field -> property). EF Core caches the
            // model per context type and rewrites references to the model-building
            // context to the currently executing one; a direct
            // Expression.Constant(_tenantProvider) bakes the FIRST instance's provider
            // into the cached model, and every later context silently filters by it.
            var tenantIdProp = Expression.Property(
                Expression.Convert(param, typeof(TenantEntity)),
                nameof(TenantEntity.TenantId));
            var provider = Expression.Field(
                Expression.Constant(this), nameof(_tenantProvider));
            var currentTenant = Expression.Property(
                provider, nameof(ITenantProvider.TenantId));
            var tenantMatch = Expression.Equal(tenantIdProp, currentTenant);
 
            // Combine: !IsDeleted && TenantId == current
            var combined = Expression.AndAlso(notDeleted, tenantMatch);
            var lambda = Expression.Lambda(combined, param);
 
            modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
        }
    }
 
    public override Task<int> SaveChangesAsync(
        bool acceptAllChangesOnSuccess, CancellationToken ct = default)
    {
        var now = DateTime.UtcNow;
 
        foreach (var entry in ChangeTracker.Entries<TenantEntity>())
        {
            if (entry.State == EntityState.Added)
                // Automatically stamp new entities with current tenant
                entry.Entity.TenantId = _tenantProvider.TenantId;
        }
 
        foreach (var entry in ChangeTracker.Entries<SoftDeletableEntity>())
        {
            if (entry.State == EntityState.Deleted)
            {
                entry.State = EntityState.Modified;
                entry.Entity.IsDeleted = true;
                entry.Entity.DeletedAt = now;
            }
        }
 
        foreach (var entry in ChangeTracker.Entries<AuditableEntity>())
        {
            if (entry.State == EntityState.Added)
                entry.Entity.CreatedAt = now;
            if (entry.State is EntityState.Added or EntityState.Modified)
                entry.Entity.UpdatedAt = now;
        }
 
        return base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
    }
}
⚠️

How the filter reaches the provider decides whether multi-tenancy actually works. EF Core builds the model once per context type and caches it — the filter expression is part of that cached model. What makes per-request tenants work is a rewrite step: constants referencing the model-building context instance are swapped for the executing context on every query, and member access hanging off that constant (this._tenantProvider.TenantId) is then evaluated fresh per query. Reference the provider directly — Expression.Constant(_tenantProvider), or a captured local like var tid = _tenantProvider.TenantId — and there is nothing to rewrite: the first request's provider (or value) is baked into the cached model, and every subsequent context instance silently filters by the first tenant. The sample proves this with a deliberately broken context: its second instance, constructed with a tenant-B provider, still returns tenant A's rows.

How Filters Apply to JOINs and Navigation Properties

Query filters are applied to every SQL query EF Core generates for that entity type, including those generated by Include() and implicit joins through navigation properties.

// This query:
var blog = await db.Blogs
    .Include(b => b.Posts)
    .FirstOrDefaultAsync(b => b.Id == id);
 
// Generates SQL roughly like:
// SELECT b.*, p.*
// FROM Blogs b
// LEFT JOIN Posts p ON p.BlogId = b.Id
//   AND p.IsDeleted = 0          -- applied automatically from Post's filter
// WHERE b.IsDeleted = 0          -- applied automatically from Blog's filter
//   AND b.Id = @id

This means:

  • blog.Posts will never contain soft-deleted posts.
  • blog.Posts.Count reflects only active posts.
  • You do not need to filter navigation properties manually.

The same applies to multi-tenant entities: if Invoice and InvoiceLineItem both have the tenant filter, loading an invoice with its line items only returns line items belonging to the same tenant.

// Cross-tenant data leaks are prevented even through navigations
var invoice = await db.Invoices
    .Include(i => i.LineItems)  // LineItems filtered by TenantId automatically
    .FirstOrDefaultAsync(i => i.Id == invoiceId);

Performance: Indexes

Without an index on IsDeleted (and TenantId), every query scans the full table. Add targeted indexes in OnModelCreating.

Partial Index for Soft Delete (SQL Server / PostgreSQL)

A partial index on IsDeleted = 0 is far smaller than a full index and speeds up the overwhelming majority of queries (which only need active rows):

// SQL Server syntax
modelBuilder.Entity<Post>()
    .HasIndex(p => p.IsDeleted)
    .HasFilter("[IsDeleted] = 0")
    .HasDatabaseName("IX_Posts_Active");
 
// PostgreSQL syntax (via HasFilter with lowercase)
modelBuilder.Entity<Post>()
    .HasIndex(p => p.IsDeleted)
    .HasFilter("\"IsDeleted\" = false")
    .HasDatabaseName("IX_Posts_Active");

Composite Index for Multi-Tenancy

For tenant-scoped queries, a composite index (TenantId, IsDeleted) allows the database to seek directly to the tenant's active rows:

modelBuilder.Entity<Invoice>()
    .HasIndex(i => new { i.TenantId, i.IsDeleted })
    .HasDatabaseName("IX_Invoices_Tenant_Active");
 
// For queries that also filter on a business column (e.g., IssuedAt):
modelBuilder.Entity<Invoice>()
    .HasIndex(i => new { i.TenantId, i.IsDeleted, i.IssuedAt })
    .HasDatabaseName("IX_Invoices_Tenant_Active_Date");

The generated migration for these indexes:

migrationBuilder.CreateIndex(
    name: "IX_Invoices_Tenant_Active",
    table: "Invoices",
    columns: new[] { "TenantId", "IsDeleted" });

Bypassing Filters

Use IgnoreQueryFilters() when you legitimately need to see all data: admin dashboards, audit logs, background jobs that operate across tenants, or data migrations.

// See all invoices across all tenants (superadmin only)
var allInvoices = await db.Invoices
    .IgnoreQueryFilters()
    .ToListAsync();
 
// Count soft-deleted posts for a cleanup job
var deletedCount = await db.Posts
    .IgnoreQueryFilters()
    .CountAsync(p => p.IsDeleted);
 
// Restore all soft-deleted tags
var deletedTags = await db.Tags
    .IgnoreQueryFilters()
    .Where(t => t.IsDeleted)
    .ToListAsync();
⚠️

IgnoreQueryFilters() removes ALL filters on that entity type, including both soft delete and tenant filters. In a multi-tenant application, calls to IgnoreQueryFilters() must be restricted to superadmin roles and audited carefully to avoid cross-tenant data leaks.

⚠️

The fixup trap: filters are SQL-level, but the change tracker doesn't care about them. Run an IgnoreQueryFilters() query and the deleted (or cross-tenant) rows are now tracked — a later filtered Include() in the same context excludes them from the SQL JOIN, yet navigation fixup attaches the already-tracked entities to the results anyway. After bypassing filters, do your filtered work in a fresh context, or query with AsNoTracking().

Ignoring Only One Filter (Named Filters, EF Core 10)

EF Core 10 added named query filters: give each filter a name, register several on the same entity (EF Core ANDs them), and drop only the ones you name. This solves the classic admin-restore problem — show a tenant's own deleted rows without dropping tenant isolation:

// Register soft delete and tenant isolation as two named filters
modelBuilder.Entity<Invoice>()
    .HasQueryFilter("SoftDelete", i => !i.IsDeleted)
    .HasQueryFilter("Tenant", i => i.TenantId == _tenantProvider.TenantId);
 
// Admin restore screen: deleted rows, still tenant-scoped
var withDeleted = await db.Invoices
    .IgnoreQueryFilters(["SoftDelete"])   // drops ONLY the soft-delete filter
    .ToListAsync();

The sample asserts exactly this: IgnoreQueryFilters(["SoftDelete"]) returns tenant A's deleted invoice while tenant B's rows stay hidden.

On EF Core 8/9, where filters have no names and IgnoreQueryFilters() is all-or-nothing, the workarounds are separate DbContext configurations or a scoped flag:

public class AppDbContext : DbContext
{
    // Scoped per-request; set to true in admin endpoints
    public bool IgnoreSoftDeleteFilter { get; set; }
 
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // The lambda reads the flag at query time
        modelBuilder.Entity<Post>()
            .HasQueryFilter(p => IgnoreSoftDeleteFilter || !p.IsDeleted);
    }
}

Use the flag sparingly — it makes testing harder and can cause subtle bugs if a DbContext is reused across requests. Prefer named filters once you're on EF Core 10.

Testing with Query Filters

Strategy 1: Test Through the Filter (Default)

Most tests should exercise the filter as production code does. Seed both deleted and active data; assert only active data comes back:

[Fact]
public async Task GetPosts_ExcludesSoftDeletedPosts()
{
    // Arrange — use in-memory or SQLite provider
    var options = new DbContextOptionsBuilder<AppDbContext>()
        .UseSqlite("Data Source=:memory:")
        .Options;
 
    await using var db = new AppDbContext(options, new FakeTenantProvider(Guid.NewGuid()));
    await db.Database.EnsureCreatedAsync();
 
    var tenantId = db.CurrentTenantId; // helper or known value
    db.Posts.AddRange(
        new Post { Title = "Active", TenantId = tenantId },
        new Post { Title = "Deleted", IsDeleted = true, TenantId = tenantId }
    );
    await db.SaveChangesAsync();
 
    // Act
    var posts = await db.Posts.ToListAsync();
 
    // Assert
    Assert.Single(posts);
    Assert.Equal("Active", posts[0].Title);
}

Strategy 2: Bypass the Filter in Assertions

Use IgnoreQueryFilters() in the assertion phase to verify a soft-delete actually wrote to the database without surfacing through the filter:

[Fact]
public async Task DeletePost_SetsSoftDeleteFields()
{
    // Arrange
    var options = new DbContextOptionsBuilder<AppDbContext>()
        .UseSqlite("Data Source=:memory:")
        .Options;
 
    var tenantId = Guid.NewGuid();
    await using var db = new AppDbContext(options, new FakeTenantProvider(tenantId));
    await db.Database.EnsureCreatedAsync();
 
    var post = new Post { Title = "To Delete", TenantId = tenantId };
    db.Posts.Add(post);
    await db.SaveChangesAsync();
 
    // Act — trigger soft delete via Remove + SaveChanges
    db.Posts.Remove(post);
    await db.SaveChangesAsync();
 
    // Assert — bypass filter to inspect the underlying row
    var deletedPost = await db.Posts
        .IgnoreQueryFilters()
        .FirstAsync(p => p.Id == post.Id);
 
    Assert.True(deletedPost.IsDeleted);
    Assert.NotNull(deletedPost.DeletedAt);
}

Fake Tenant Provider for Tests

public class FakeTenantProvider : ITenantProvider
{
    public FakeTenantProvider(Guid tenantId) => TenantId = tenantId;
    public Guid TenantId { get; }
}

Owned Entity Filters

EF Core does not support HasQueryFilter on owned entity types (entities configured with OwnsOne or OwnsMany). Owned entities are always loaded as part of their owner and do not have independent queries, so the filter on the owner entity covers them.

public class Customer : AuditableEntity
{
    public string Name { get; set; } = string.Empty;
    // Owned — loaded with Customer, filter on Customer covers access
    public Address BillingAddress { get; set; } = null!;
}
 
[Owned]
public class Address
{
    public string Street { get; set; } = string.Empty;
    public string City { get; set; } = string.Empty;
    public string PostalCode { get; set; } = string.Empty;
}
 
// In OnModelCreating:
modelBuilder.Entity<Customer>()
    .OwnsOne(c => c.BillingAddress);
 
// The soft-delete filter on Customer already covers Address —
// no separate filter needed or possible for Address.

If you apply HasQueryFilter to an owned type, EF Core throws an InvalidOperationException at startup.

Full DbContext Configuration Example

public class AppDbContext : DbContext
{
    private readonly ITenantProvider _tenantProvider;
 
    public AppDbContext(
        DbContextOptions<AppDbContext> options,
        ITenantProvider tenantProvider)
        : base(options)
    {
        _tenantProvider = tenantProvider;
    }
 
    public DbSet<Blog> Blogs => Set<Blog>();
    public DbSet<Post> Posts => Set<Post>();
    public DbSet<Tag> Tags => Set<Tag>();
    public DbSet<Invoice> Invoices => Set<Invoice>();
    public DbSet<InvoiceLineItem> InvoiceLineItems => Set<InvoiceLineItem>();
    public DbSet<Customer> Customers => Set<Customer>();
 
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
 
        // Owned types
        modelBuilder.Entity<Customer>()
            .OwnsOne(c => c.BillingAddress);
 
        // Configure filters
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            // Skip owned entities — they cannot have independent filters
            if (entityType.IsOwned()) continue;
 
            var clrType = entityType.ClrType;
 
            if (typeof(TenantEntity).IsAssignableFrom(clrType))
            {
                // Combined: !IsDeleted && TenantId == current
                var param = Expression.Parameter(clrType, "e");
                var isDeleted = Expression.Property(
                    Expression.Convert(param, typeof(SoftDeletableEntity)),
                    nameof(SoftDeletableEntity.IsDeleted));
                var tenantId = Expression.Property(
                    Expression.Convert(param, typeof(TenantEntity)),
                    nameof(TenantEntity.TenantId));
                // Through the context constant — see the warning above: a constant of
                // the provider itself gets baked into the cached model.
                var currentTenant = Expression.Property(
                    Expression.Field(Expression.Constant(this), nameof(_tenantProvider)),
                    nameof(ITenantProvider.TenantId));
 
                var filter = Expression.AndAlso(
                    Expression.Not(isDeleted),
                    Expression.Equal(tenantId, currentTenant));
                modelBuilder.Entity(clrType).HasQueryFilter(Expression.Lambda(filter, param));
            }
            else if (typeof(SoftDeletableEntity).IsAssignableFrom(clrType))
            {
                // Soft delete only
                var param = Expression.Parameter(clrType, "e");
                var isDeleted = Expression.Property(
                    Expression.Convert(param, typeof(SoftDeletableEntity)),
                    nameof(SoftDeletableEntity.IsDeleted));
                modelBuilder.Entity(clrType).HasQueryFilter(
                    Expression.Lambda(Expression.Not(isDeleted), param));
            }
        }
 
        // Performance indexes
        modelBuilder.Entity<Post>()
            .HasIndex(p => p.IsDeleted)
            .HasFilter("[IsDeleted] = 0");
 
        modelBuilder.Entity<Blog>()
            .HasIndex(b => b.IsDeleted)
            .HasFilter("[IsDeleted] = 0");
 
        modelBuilder.Entity<Invoice>()
            .HasIndex(i => new { i.TenantId, i.IsDeleted });
 
        modelBuilder.Entity<InvoiceLineItem>()
            .HasIndex(li => new { li.TenantId, li.IsDeleted });
 
        modelBuilder.Entity<Customer>()
            .HasIndex(c => c.IsDeleted)
            .HasFilter("[IsDeleted] = 0");
    }
 
    // The bool overload — all four public SaveChanges entry points funnel through it,
    // so synchronous SaveChanges() callers are intercepted too.
    public override Task<int> SaveChangesAsync(
        bool acceptAllChangesOnSuccess, CancellationToken ct = default)
    {
        var now = DateTime.UtcNow;
 
        foreach (var entry in ChangeTracker.Entries())
        {
            // Auto-assign tenant on new tenant entities
            if (entry.Entity is TenantEntity tenantEntity
                && entry.State == EntityState.Added)
            {
                tenantEntity.TenantId = _tenantProvider.TenantId;
            }
 
            // Intercept hard deletes, convert to soft deletes
            if (entry.Entity is SoftDeletableEntity softEntity
                && entry.State == EntityState.Deleted)
            {
                entry.State = EntityState.Modified;
                softEntity.IsDeleted = true;
                softEntity.DeletedAt = now;
            }
 
            // Audit timestamps
            if (entry.Entity is AuditableEntity auditEntity)
            {
                if (entry.State == EntityState.Added)
                    auditEntity.CreatedAt = now;
                if (entry.State is EntityState.Added or EntityState.Modified)
                    auditEntity.UpdatedAt = now;
            }
        }
 
        return base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
    }
}

DI Registration

// Program.cs
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantProvider, HttpContextTenantProvider>();
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

Verify It Yourself

Every claim above is asserted by samples/ef-core-global-query-filters — 22 checks that throw on failure (EF Core 10, SQL Server LocalDB). The highlights:

  • A plain query, an Include() JOIN, and a count all exclude soft-deleted rows; a line item deliberately seeded with the wrong TenantId never leaks through Include(i => i.LineItems).
  • Remove() + SaveChangesAsync() executes exactly one UPDATE — the row survives with IsDeleted = true, and the restore pattern brings it back.
  • Flipping a mutable tenant provider on the same context switches results per query; a new context with a different provider sees its own tenant — because the filter references the provider through the context.
  • The deliberately broken variant (Expression.Constant(provider)) passes its first use and then returns tenant A's rows to a tenant-B context, exactly as the model-caching warning predicts.
  • The fixup trap reproduces: after IgnoreQueryFilters(), a filtered Include() in the same context shows the deleted post again via tracked-entity fixup.
  • ExecuteDelete hard-deletes straight through the SaveChanges interception.
  • IgnoreQueryFilters(["SoftDelete"]) surfaces the current tenant's deleted invoice while tenant B stays hidden — named filters really do compose.
  • HasQueryFilter on an owned type throws InvalidOperationException, and the partial index is read back from sys.indexes as ([IsDeleted]=(0)).
Console output of the global-query-filters sample: 22 passing checks. Soft delete — plain queries return 2 of 3 posts, IgnoreQueryFilters shows all 3, the fixup trap makes a filtered Include show the deleted post again after an unfiltered query in the same context, Remove plus SaveChanges runs exactly one UPDATE and the restore pattern brings the row back. Multi-tenancy — tenant A sees only its active invoice, the line item stamped with tenant B never leaks through Include, IgnoreQueryFilters with the SoftDelete name shows the deleted invoice while still hiding tenant B, flipping the provider on the same context switches tenants per query, and ExecuteDelete hard-deletes through the interception. The Expression.Constant(provider) trap — a second buggy context built with a tenant-B provider still sees tenant A because the first provider is baked into the cached model. Owned entities — HasQueryFilter on Address throws InvalidOperationException, and the partial index reads back from sys.indexes as IsDeleted equals zero.
All 22 checks, straight from the console — including the two rows that matter most: the buggy context returning tenant A's rows to a tenant-B provider, and IgnoreQueryFilters(["SoftDelete"]) keeping tenant isolation intact.
💡

Run its seed.sql first, then dotnet run. One practical note from writing it: create filtered indexes with QUOTED_IDENTIFIER ON — sqlcmd defaults it OFF and the CREATE INDEX ... WHERE fails.

Summary

ScenarioApproach
Soft delete all entitiesExpression tree loop over SoftDeletableEntity subtypes in OnModelCreating
Multi-tenancyScoped ITenantProvider referenced through the context in the filter; combine with soft-delete filter
See deleted recordsIgnoreQueryFilters() on the query
Restore a recordFetch with IgnoreQueryFilters(), set IsDeleted = false, save
Navigation property filteringAutomatic — EF Core applies filters on included entities
Partial indexHasIndex(...).HasFilter("[IsDeleted] = 0")
TestingSeed deleted + active data; assert only active rows returned; use IgnoreQueryFilters() in assertion phase to verify soft-delete writes
Owned entitiesNo filter possible; covered by owner entity's filter

Further reading

About the author

Jorge Calderón

Software engineer with over a decade building and operating .NET applications in production — EF Core data layers, async-heavy services, and Azure and container deployments. Every benchmark and sample project in these guides is published in a public GitHub repository so you can rerun it yourself.

GitHub profileLinkedIn ↗Benchmarks & sample code

Related articles