EF Core 10 Named Query Filters: The Multi-Tenant Game Changer We've Been Waiting For
If you've shipped a multi-tenant app with EF Core, you already know what I'm about to say. You get one query filter per entity. One. Want tenant isolation AND soft deletes? Mash them into the same lambda and hope nobody ever needs to turn off just one of them.
I've lived with this limitation across multiple SaaS products. EF Core 10 finally kills it with named query filters — and honestly, for the kind of work I do, this might be the most impactful feature in the entire release.
The Old Pain: One Filter to Rule Them All
EF Core introduced HasQueryFilter() back in version 2.0. Solid idea — define a global filter on an entity, and every query picks it up automatically. Perfect for soft deletes:
modelBuilder.Entity<Order>()
.HasQueryFilter(o => !o.IsDeleted);
Clean and simple. Until you need a second filter.
Any production multi-tenant system needs at minimum two global filters: tenant isolation and soft deletes. Since EF Core only allowed one HasQueryFilter per entity, you'd cram them together:
modelBuilder.Entity<Order>()
.HasQueryFilter(o => !o.IsDeleted && o.TenantId == _currentTenantId);
This holds up until someone asks for an admin dashboard that shows deleted records but must still enforce tenant boundaries. Your only escape hatch? IgnoreQueryFilters() — which rips out everything, tenant isolation included. Now you're one missed .Where() clause away from leaking data across tenants.
I've watched teams build entire middleware layers, custom expression visitors, and pull in third-party dependencies just to work around this single limitation. That's a lot of complexity for a missing overload.
Named Query Filters: How They Work
EF Core 10 adds a new overload of HasQueryFilter that takes a name:
modelBuilder.Entity<Order>(entity =>
{
entity.HasQueryFilter("TenantFilter",
o => o.TenantId == _currentTenantId);
entity.HasQueryFilter("SoftDeleteFilter",
o => !o.IsDeleted);
entity.HasQueryFilter("ActiveSubscriptionFilter",
o => o.Subscription.IsActive);
});
Three filters. One entity. Each with a clear name. The generated SQL includes all three as WHERE conditions — nothing fancy happening under the hood.
Pick naming conventions that are obvious. "TenantFilter", "SoftDeleteFilter", "VisibilityFilter" — names that tell you exactly what boundary they enforce when you're reading this code at 11pm trying to figure out why a query isn't returning what you expected.
Selective Disabling: Where It Gets Good
Stacking multiple filters is useful. Selectively disabling them is where this actually changes how you architect things.
EF Core 10 adds IgnoreQueryFilter with a name parameter:
// Admin needs to see deleted orders — but ONLY within their tenant
var deletedOrders = await context.Orders
.IgnoreQueryFilter("SoftDeleteFilter")
.Where(o => o.IsDeleted)
.ToListAsync();
Tenant filter stays active. Soft-delete filter is bypassed. No manual .Where() to re-add. No risk of forgetting the tenant boundary.
Now look at how you had to do this before:
// EF Core 9: The dangerous way
var deletedOrders = await context.Orders
.IgnoreQueryFilters() // Removes ALL filters including tenant isolation!
.Where(o => o.TenantId == currentTenantId) // Hope you don't forget this
.Where(o => o.IsDeleted)
.ToListAsync();
One is a security liability waiting to happen. The other is a one-liner that does exactly what you'd expect. Not even close.
A Pattern I've Been Using in Production
Here's the approach I've landed on that makes named filters scale across a full domain model. It's built on marker interfaces — implement the interface, get the filter automatically.
Start with the interfaces:
public interface ITenantScoped
{
string TenantId { get; }
}
public interface ISoftDeletable
{
bool IsDeleted { get; }
DateTime? DeletedAt { get; }
}
public interface IFeatureGated
{
string RequiredFeatureFlag { get; }
}
Then wire up named filters in your DbContext based on what each entity implements:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(ITenantScoped).IsAssignableFrom(entityType.ClrType))
{
modelBuilder.Entity(entityType.ClrType)
.HasQueryFilter("TenantFilter",
BuildTenantFilter(entityType.ClrType));
}
if (typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType))
{
modelBuilder.Entity(entityType.ClrType)
.HasQueryFilter("SoftDeleteFilter",
BuildSoftDeleteFilter(entityType.ClrType));
}
}
}
Every ITenantScoped entity gets tenant isolation. Every ISoftDeletable entity gets soft-delete filtering. Independently. No combined lambdas. No workarounds. Add a new entity, slap the interface on it, and the filters are there.
Your repository code becomes explicit about what it's doing:
public async Task<List<Order>> GetDeletedOrdersForAdmin()
{
return await _context.Orders
.IgnoreQueryFilter("SoftDeleteFilter")
.Where(o => o.IsDeleted)
.OrderByDescending(o => o.DeletedAt)
.ToListAsync();
// Tenant filter is STILL active — admin only sees their tenant's deleted orders
}
public async Task<List<Order>> GetAllOrdersForSuperAdmin()
{
return await _context.Orders
.IgnoreQueryFilter("TenantFilter")
.IgnoreQueryFilter("SoftDeleteFilter")
.ToListAsync();
// Only super-admin endpoints should call this — both filters disabled
}
What I like about this: the intent is visible. When someone reviews a PR and sees IgnoreQueryFilter("TenantFilter"), that's a red flag worth discussing. With the old IgnoreQueryFilters(), you had no idea which filter the developer actually wanted to bypass — they were just nuking all of them.
This pattern eliminates entire categories of multi-tenant data-leak bugs. The tenant boundary is on by default, and opting out requires a deliberate, named call that's impossible to miss in review.
Migrating from EF Core 9
The good news: nothing breaks. The existing HasQueryFilter() without a name still works exactly as before. You can upgrade to EF Core 10 and change nothing.
I'd recommend a gradual migration:
- Upgrade to EF Core 10 — existing unnamed filters keep working
- Find your entities with combined filters — these are the priority
- Split combined filters into named ones — one concern per filter
- Swap
IgnoreQueryFilters()for targetedIgnoreQueryFilter("name")— this is the security win - Add filters you couldn't before — feature flags, data visibility tiers, regional compliance rules
On performance: named filters compile down to the same SQL WHERE clauses as before. No runtime overhead. Filter resolution happens at query compilation time, and EF Core caches compiled queries anyway.
What This Actually Eliminates
Named query filters remove the need for:
- Combined filter lambdas that mix unrelated concerns into one expression
- Manual
.Where()clauses you had to remember to re-add after disabling filters - Custom expression visitors to selectively toggle individual filters at runtime
- Third-party libraries built entirely around this one limitation
- That nagging feeling during code review that someone forgot to re-add the tenant clause after calling
IgnoreQueryFilters()
One line to define. One line to disable. The API finally matches how we think about data boundaries — as separate, named concerns that you control independently.
If you're building multi-tenant SaaS on .NET, this is the EF Core 10 feature worth paying attention to. It takes a pattern that used to demand careful engineering and constant vigilance and makes it boring. And in platform engineering and production architecture, boring infrastructure is the goal.