If you've ever built a multi-tenant application with soft deletes in EF Core, you've hit this wall: you can only have one global query filter per entity type. Need both a tenant filter and a soft-delete filter on the same entity? You had to cram them into a single lambda expression with &&, and if you wanted to disable just the soft-delete filter for an admin view while keeping tenant isolation? Tough luck. IgnoreQueryFilters() was all-or-nothing.

EF Core 10 fixes this with named query filters -- a feature that lets you attach multiple independently managed filters to a single entity type. Each filter gets a name, and you can disable them individually. It sounds like a small change, but it fundamentally alters how you design cross-cutting data access concerns.

The old workaround and why it hurt

Before EF Core 10, calling HasQueryFilter a second time on the same entity simply replaced the first filter. The documented workaround was to combine your filters into a single expression:

Data/AppDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>()
        .HasQueryFilter(o => !o.IsDeleted && o.TenantId == _tenantId);
}

This works, but it creates problems that compound as your application grows:

Defining named filters

EF Core 10 introduces a string name overload on HasQueryFilter. Filters with different names coexist rather than replacing each other:

Data/AppDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>()
        .HasQueryFilter("SoftDelete", o => !o.IsDeleted)
        .HasQueryFilter("Tenant", o => o.TenantId == _tenantId);
}

Both filters are applied to every query against Order by default. The generated SQL includes both WHERE conditions:

query.sql
SELECT [o].[Id], [o].[TenantId], [o].[IsDeleted], [o].[Total]
FROM [Orders] AS [o]
WHERE [o].[IsDeleted] = CAST(0 AS bit)
  AND [o].[TenantId] = @__tenantId_0

There's no limit to the number of named filters on a single entity. If you need a third filter for, say, an approval status, just chain another HasQueryFilter call with a unique name.

Selective disabling with IgnoreQueryFilters

The real payoff comes from selective disabling. IgnoreQueryFilters now accepts an optional array of filter names:

Features/Admin/OrderAdminService.cs
public class OrderAdminService(AppDbContext context)
{
    // Admin view: show soft-deleted orders but still respect tenant isolation
    public async Task<List<Order>> GetAllOrdersIncludingDeletedAsync()
    {
        return await context.Orders
            .IgnoreQueryFilters(["SoftDelete"])
            .OrderByDescending(o => o.CreatedAt)
            .ToListAsync();
    }
}

This disables only the SoftDelete filter. The Tenant filter remains active, so you can't accidentally leak data across tenants. The generated SQL reflects this:

query.sql
SELECT [o].[Id], [o].[TenantId], [o].[IsDeleted], [o].[Total], [o].[CreatedAt]
FROM [Orders] AS [o]
WHERE [o].[TenantId] = @__tenantId_0
ORDER BY [o].[CreatedAt] DESC

You can disable multiple named filters at once by passing their names:

Example.cs
// Disable both -- equivalent to the old parameterless IgnoreQueryFilters()
var everything = await context.Orders
    .IgnoreQueryFilters(["SoftDelete", "Tenant"])
    .ToListAsync();

And calling IgnoreQueryFilters() without arguments still disables all filters, so existing code that uses it continues to work as expected.

Organising filters across configuration classes

Named filters shine when you use IEntityTypeConfiguration<T> to separate concerns. Each configuration class can register its own named filter without worrying about overwriting another:

Data/Configurations/SoftDeleteConfiguration.cs
public class SoftDeleteConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasQueryFilter("SoftDelete", o => !o.IsDeleted);
    }
}
Data/Configurations/TenantConfiguration.cs
public class TenantConfiguration : IEntityTypeConfiguration<Order>
{
    private readonly AppDbContext _context = null!;

    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasQueryFilter("Tenant", o => o.TenantId == _context.TenantId);
    }
}

This keeps your filter logic co-located with the concern it represents. The dummy context trick for accessing the tenant ID inside IEntityTypeConfiguration is unchanged from previous versions -- EF Core captures the context instance at runtime through the expression tree.

A reusable soft-delete convention

If your domain has many soft-deletable entities, you can apply the SoftDelete filter automatically using a model-building convention or a shared helper. Here's a straightforward approach using a marker interface:

Data/SoftDeleteExtensions.cs
public interface ISoftDeletable
{
    bool IsDeleted { get; set; }
}

public static class SoftDeleteExtensions
{
    public static void ApplySoftDeleteFilters(this ModelBuilder modelBuilder)
    {
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            if (!typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType))
                continue;

            var parameter = Expression.Parameter(entityType.ClrType, "e");
            var property = Expression.Property(parameter, nameof(ISoftDeletable.IsDeleted));
            var filter = Expression.Lambda(Expression.Not(property), parameter);

            entityType.SetQueryFilter("SoftDelete", filter);
        }
    }
}

// NOTE

The SetQueryFilter overload with a name parameter is the metadata-level equivalent of HasQueryFilter with a name. It works directly on IMutableEntityType, which is useful in conventions and bulk configuration scenarios.

Call this in OnModelCreating after your other configuration:

Data/AppDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    modelBuilder.ApplySoftDeleteFilters();
}

Every entity that implements ISoftDeletable now gets a consistently named SoftDelete filter that can be selectively disabled anywhere in your application.

Multi-tenancy with named filters

Multi-tenancy is the other classic use case for global filters, and named filters make the pattern considerably safer. Here's a complete setup:

Data/TenantDbContext.cs
public class TenantDbContext(
    DbContextOptions<TenantDbContext> options,
    ITenantProvider tenantProvider) : DbContext(options)
{
    public string TenantId { get; } = tenantProvider.GetCurrentTenantId();

    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Customer> Customers => Set<Customer>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>()
            .HasQueryFilter("Tenant", o => o.TenantId == TenantId)
            .HasQueryFilter("SoftDelete", o => !o.IsDeleted);

        modelBuilder.Entity<Customer>()
            .HasQueryFilter("Tenant", c => c.TenantId == TenantId)
            .HasQueryFilter("SoftDelete", c => !c.IsDeleted);
    }
}

The key advantage: when you build an admin endpoint that needs to show deleted customers, you can disable SoftDelete while the Tenant filter continues to provide data isolation. Before EF Core 10, this pattern required custom query-rewriting middleware or manual WHERE clauses on every admin query.

Common pitfalls

Mixing named and unnamed filters on the same entity. EF Core 10 does not allow both named and unnamed filters on the same entity type. If you add a named filter, all filters on that entity must be named. Attempting to mix them throws a configuration exception:

Example.cs
// Bad -- throws at model building time
modelBuilder.Entity<Order>()
    .HasQueryFilter(o => !o.IsDeleted)                       // unnamed
    .HasQueryFilter("Tenant", o => o.TenantId == _tenantId); // named

If you're migrating from an unnamed filter, convert it to a named filter at the same time.

Typos in filter names when disabling. IgnoreQueryFilters accepts strings, so a misspelled name silently does nothing -- the filter you intended to disable stays active. Consider defining filter name constants:

Data/FilterNames.cs
public static class FilterNames
{
    public const string SoftDelete = "SoftDelete";
    public const string Tenant = "Tenant";
}

Then reference these everywhere:

Example.cs
builder.HasQueryFilter(FilterNames.SoftDelete, o => !o.IsDeleted);

// Later, in a query
context.Orders.IgnoreQueryFilters([FilterNames.SoftDelete]);

Forgetting that filters apply to navigation properties too. This is not new to EF Core 10, but it's worth repeating because named filters don't change the behaviour. If Blog has a filter and Post has a required navigation to Blog, querying posts with Include(p => p.Blog) uses an INNER JOIN, and posts whose blog is filtered out disappear from the results. The fix is either to make the navigation optional (which generates a LEFT JOIN) or to apply consistent filters across related entities.

Overusing filters where a query abstraction would suffice. Global query filters are powerful precisely because they're invisible -- every query gets them automatically. But "invisible" can also mean "surprising" when a developer unfamiliar with the codebase doesn't realise a filter exists. If a filter only applies to a handful of queries, a shared extension method or specification pattern might be clearer than a global filter.

Migrating from the old approach

If you already have combined filters using &&, migrating is straightforward:

  1. Split the combined lambda into separate HasQueryFilter calls, each with a meaningful name.
  2. Replace any IgnoreQueryFilters() calls that were disabling all filters with targeted IgnoreQueryFilters(["FilterName"]) calls where appropriate.
  3. Review admin or internal endpoints that were working around the all-or-nothing limitation. They likely have manual WHERE clauses or raw SQL that can now be replaced with selective filter disabling.

There is no database migration needed -- named query filters are a model-level concept, not a schema change.

Summary