Skip to main content

Finished reading? Get articles like this every Tuesday

Global Query Filters in EF Core - Soft Delete, Multi-Tenancy & Named Filters in .NET 10

Master global query filters in EF Core 10 with named filters, soft delete, multi-tenancy, IgnoreQueryFilters, and performance tips.

dotnet webapi-course

efcore global-query-filters soft-delete multi-tenancy hasqueryfilter named-query-filters entity-framework dotnet-10 ef-core-10 ignorequeryfilters webapi postgresql data-access query-optimization dotnet-webapi-zero-to-hero-course ientitytypeconfiguration fluent-api

17 min read
1.6K views

Every production API eventually needs to answer the same questions: How do we exclude deleted records from every query? How do we ensure Tenant A never sees Tenant B’s data? And how do we do all of this without sprinkling .Where(x => !x.IsDeleted && x.TenantId == currentTenant) across every single query in the codebase?

If you’ve been writing those repetitive WHERE clauses manually, you already know the pain - one missed filter is a data leak waiting to happen. Global Query Filters in EF Core (Entity Framework Core) solve this by letting you define filters once at the model level and having EF Core automatically apply them to every query. In EF Core 10, this feature got a major upgrade with named query filters, which let you define multiple independent filters per entity and selectively disable them.

In this article, we’ll implement soft delete and multi-tenancy using global query filters, explore the new named filter syntax in EF Core 10, handle the common gotchas that trip developers up, and cover performance best practices. Let’s get into it.

What Are Global Query Filters?

A Global Query Filter in EF Core is a LINQ predicate defined at the model level that automatically adds a WHERE clause to every query generated for a specific entity type. Once configured, EF Core silently appends the filter condition to all LINQ queries, Include() calls, and navigation property loads for that entity - no manual filtering required.

Think of it as a default lens through which your application sees data. Every query goes through this lens unless you explicitly choose to remove it.

The two most common use cases are:

  • Soft delete - Automatically exclude records marked as deleted, without physically removing them from the database
  • Multi-tenancy - Ensure queries only return data belonging to the current tenant, preventing cross-tenant data leaks

Global query filters are configured inside OnModelCreating using the HasQueryFilter method on the Fluent API. Here’s the simplest example:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);
}

With this single line, every query against Products automatically includes WHERE IsDeleted = false. You don’t need to remember to add the filter - EF Core handles it for you.

Prerequisites

Before we start, make sure you have:

  • .NET 10 SDK installed (download here)
  • Docker Desktop running (we’ll use PostgreSQL in a container)
  • A code editor - Visual Studio 2026 or VS Code with the C# extension

Setting Up the Project

Let’s create a .NET 10 Web API project that demonstrates global query filters in action. We’ll build a simple product catalog API with soft delete and multi-tenancy support.

Terminal window
dotnet new webapi -n GlobalQueryFilters.Api -o GlobalQueryFilters.Api --use-controllers false

Install the required NuGet packages:

Terminal window
dotnet add GlobalQueryFilters.Api package Npgsql.EntityFrameworkCore.PostgreSQL --version 10.0.0
dotnet add GlobalQueryFilters.Api package Microsoft.EntityFrameworkCore.Design --version 10.0.3
dotnet add GlobalQueryFilters.Api package Scalar.AspNetCore --version 2.11.9

Define the Entities

We’ll start with a Product entity that supports both soft delete and multi-tenancy:

namespace GlobalQueryFilters.Api.Entities;
public class Product
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public string TenantId { get; set; } = string.Empty;
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
}

And a Category entity to demonstrate how filters interact with navigation properties:

namespace GlobalQueryFilters.Api.Entities;
public class Category
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public bool IsDeleted { get; set; }
public List<Product> Products { get; set; } = [];
}

Now update Product to include the Category navigation:

public class Product
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public string TenantId { get; set; } = string.Empty;
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public Guid CategoryId { get; set; }
public Category Category { get; set; } = null!;
}

Implementing Soft Delete with Global Query Filters

Soft delete is the most common use case for global query filters. Instead of physically deleting records with DELETE FROM, you set an IsDeleted flag to true. The global query filter ensures that these “deleted” records are invisible to regular queries.

Configure the Filter in DbContext

Create the AppDbContext and configure the soft delete filter:

using GlobalQueryFilters.Api.Entities;
using Microsoft.EntityFrameworkCore;
namespace GlobalQueryFilters.Api.Data;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
public DbSet<Category> Categories => Set<Category>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);
modelBuilder.Entity<Category>().HasQueryFilter(c => !c.IsDeleted);
}
}

With this configuration, every query against Products or Categories automatically filters out deleted records:

// EF Core generates: SELECT ... FROM Products WHERE IsDeleted = false
var activeProducts = await context.Products.ToListAsync();

You don’t need to add .Where(p => !p.IsDeleted) anywhere in your application code - the filter is applied automatically.

Overriding SaveChanges for Automatic Soft Delete

Having the query filter is only half the solution. You also need to intercept delete operations and convert them to soft deletes. Override SaveChangesAsync in your DbContext:

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
ChangeTracker.DetectChanges();
foreach (var entry in ChangeTracker.Entries<Product>()
.Where(e => e.State == EntityState.Deleted))
{
entry.State = EntityState.Modified;
entry.CurrentValues["IsDeleted"] = true;
entry.CurrentValues["DeletedAt"] = DateTime.UtcNow;
}
foreach (var entry in ChangeTracker.Entries<Category>()
.Where(e => e.State == EntityState.Deleted))
{
entry.State = EntityState.Modified;
entry.CurrentValues["IsDeleted"] = true;
}
return await base.SaveChangesAsync(cancellationToken);
}

Now when you call context.Products.Remove(product), EF Core won’t issue a DELETE statement. Instead, it’ll set IsDeleted = true and DeletedAt = DateTime.UtcNow - a proper soft delete. The global query filter handles the rest by hiding these records from future queries.

We’ll make this more DRY later when we implement the interface-based pattern. Keep reading.

Multi-Tenancy with Global Query Filters

Multi-tenancy is the other mainstream use case. In a shared database, all tenants’ data lives in the same tables. Global query filters ensure that each tenant only sees their own data - providing strong isolation at the query level.

Unlike soft delete (which uses a static boolean), multi-tenancy requires a dynamic value - the current tenant’s ID. This value changes per request and must be available to the DbContext at query time.

Create a Tenant Service

First, create a service that extracts the tenant ID from the current HTTP request:

namespace GlobalQueryFilters.Api.Services;
public interface ITenantService
{
string GetCurrentTenantId();
}
public class TenantService(IHttpContextAccessor httpContextAccessor) : ITenantService
{
public string GetCurrentTenantId()
{
var tenantId = httpContextAccessor.HttpContext?.Request.Headers["X-Tenant-Id"].FirstOrDefault();
return tenantId ?? throw new InvalidOperationException("Tenant ID header is missing.");
}
}

This reads the tenant ID from an X-Tenant-Id request header. In a production app, you’d typically extract this from JWT claims, a subdomain, or a database lookup.

Configure the Multi-Tenant Filter

Now update the DbContext to accept the tenant service and use it in the filter:

public class AppDbContext : DbContext
{
private readonly string _tenantId;
public AppDbContext(DbContextOptions<AppDbContext> options, ITenantService tenantService)
: base(options)
{
_tenantId = tenantService.GetCurrentTenantId();
}
public DbSet<Product> Products => Set<Product>();
public DbSet<Category> Categories => Set<Category>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted && p.TenantId == _tenantId);
modelBuilder.Entity<Category>().HasQueryFilter(c => !c.IsDeleted && c.TenantId == _tenantId);
}
}

Notice the _tenantId field referenced in the filter. EF Core is smart about this - it uses a parameterized query, meaning the SQL looks like WHERE TenantId = @__tenantId_0. The query plan gets cached and reused across different tenants. Only the parameter value changes.

The Problem with Combining Filters (Pre-EF Core 10)

Did you notice the filter expression? We had to combine both conditions with &&:

// Pre-EF Core 10: Only ONE HasQueryFilter call per entity
modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted && p.TenantId == _tenantId);

This works, but it has a major drawback: you can’t selectively disable one filter without disabling the other. Calling IgnoreQueryFilters() removes ALL filters at once. If you want to see soft-deleted products for an admin panel, you’d also lose the tenant isolation - a security risk.

This is exactly the problem that Named Query Filters in EF Core 10 solve.

Named Query Filters in EF Core 10

EF Core 10 introduced named query filters - the ability to define multiple independent filters per entity and manage them separately. This was tracked in the EF Core GitHub repository and is documented in the What’s New in EF Core 10 page. It’s a significant improvement over the single-filter limitation.

Instead of combining conditions into one HasQueryFilter call, you give each filter a name:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.HasQueryFilter("SoftDelete", p => !p.IsDeleted)
.HasQueryFilter("TenantIsolation", p => p.TenantId == _tenantId);
modelBuilder.Entity<Category>()
.HasQueryFilter("SoftDelete", c => !c.IsDeleted)
.HasQueryFilter("TenantIsolation", c => c.TenantId == _tenantId);
}

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

SELECT p."Id", p."Name", p."Price", p."TenantId", p."IsDeleted"
FROM "Products" AS p
WHERE p."IsDeleted" = false AND p."TenantId" = @__tenantId_0

The key difference is what happens when you need to disable one.

Why Named Filters Are Required for Multiple Filters

A critical detail: calling HasQueryFilter without a name overwrites any previous filter on the same entity:

// The second call OVERWRITES the first one
modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);
modelBuilder.Entity<Product>().HasQueryFilter(p => p.TenantId == _tenantId);
// Only the TenantId filter is active - soft delete filter is gone!

This is by design. If you need multiple filters in EF Core 10, you must name them. Each named filter is tracked independently and won’t overwrite others.

Selectively Disabling Filters with IgnoreQueryFilters

The real power of named filters shows when you need to bypass specific filters while keeping others active.

Disable All Filters

To bypass all query filters (both named and unnamed), call IgnoreQueryFilters() with no arguments:

// Removes ALL filters - both SoftDelete and TenantIsolation
var everything = await context.Products
.IgnoreQueryFilters()
.ToListAsync();

Disable Specific Named Filters

In EF Core 10, you can pass filter names to IgnoreQueryFilters to disable only specific ones:

// Disable only SoftDelete - TenantIsolation still active
var allProductsIncludingDeleted = await context.Products
.IgnoreQueryFilters(["SoftDelete"])
.ToListAsync();

This is exactly what you need for an admin “recycle bin” feature - show deleted products but only for the current tenant. The tenant isolation filter stays in place, preventing cross-tenant data access.

You can also disable multiple named filters:

// Disable both filters by name
var allData = await context.Products
.IgnoreQueryFilters(["SoftDelete", "TenantIsolation"])
.ToListAsync();
ScenarioPre-EF Core 10EF Core 10 Named Filters
Multiple filters per entityCombine with && in one callSeparate named HasQueryFilter calls
Disable all filtersIgnoreQueryFilters()IgnoreQueryFilters()
Disable ONE specific filterNot possibleIgnoreQueryFilters(["FilterName"])
Filter managementAll-or-nothingGranular per-filter control

Applying Filters via Interfaces - The DRY Pattern

Once your application has 10, 20, or 50 entities, adding HasQueryFilter to each one individually gets repetitive. A cleaner approach is to define interfaces and apply filters to all entities that implement them.

Define Marker Interfaces

namespace GlobalQueryFilters.Api.Entities;
public interface ISoftDelete
{
bool IsDeleted { get; set; }
DateTime? DeletedAt { get; set; }
}
public interface ITenantEntity
{
string TenantId { get; set; }
}

Update your entities to implement these interfaces:

public class Product : ISoftDelete, ITenantEntity
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public string TenantId { get; set; } = string.Empty;
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public Guid CategoryId { get; set; }
public Category Category { get; set; } = null!;
}

Apply Filters Dynamically in OnModelCreating

Now loop through all entity types in the model and apply filters to any entity that implements the interface:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
// Apply soft delete filter to all ISoftDelete entities
if (typeof(ISoftDelete).IsAssignableFrom(entityType.ClrType))
{
var parameter = Expression.Parameter(entityType.ClrType, "e");
var property = Expression.Property(parameter, nameof(ISoftDelete.IsDeleted));
var condition = Expression.Equal(property, Expression.Constant(false));
var lambda = Expression.Lambda(condition, parameter);
entityType.SetQueryFilter(lambda);
}
}
}

Note: The SetQueryFilter method on IMutableEntityType sets an unnamed filter. For named filters with the interface-based approach, you’ll need to use the HasQueryFilter method on EntityTypeBuilder. You can get the builder from modelBuilder.Entity(entityType.ClrType).

Make SaveChanges DRY Too

Now that we have the ISoftDelete interface, the SaveChangesAsync override becomes much cleaner:

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
ChangeTracker.DetectChanges();
foreach (var entry in ChangeTracker.Entries<ISoftDelete>()
.Where(e => e.State == EntityState.Deleted))
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = DateTime.UtcNow;
}
return await base.SaveChangesAsync(cancellationToken);
}

This handles soft delete for every entity that implements ISoftDelete - no matter how many entities you add in the future. Add the interface, and it’s covered.

Using Filters with IEntityTypeConfiguration

If you’re using IEntityTypeConfiguration<T> to organize your entity configurations (and you should be), you can apply query filters there too:

using GlobalQueryFilters.Api.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace GlobalQueryFilters.Api.Data.Configurations;
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.HasKey(p => p.Id);
builder.Property(p => p.Name).HasMaxLength(200).IsRequired();
builder.Property(p => p.Price).HasColumnType("decimal(18,2)");
// Query filter defined alongside other entity configuration
builder.HasQueryFilter("SoftDelete", p => !p.IsDeleted);
}
}

There’s a catch with multi-tenancy filters inside IEntityTypeConfiguration - you need access to the tenant ID, but you don’t have a DbContext instance. The workaround is to reference a dummy context field:

public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
private readonly AppDbContext _context = null!;
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.HasQueryFilter("TenantIsolation", p => p.TenantId == _context._tenantId);
}
}

EF Core resolves _context._tenantId at query time, not at configuration time. The null! assignment is intentional - this object is never used directly. EF Core captures the expression tree and evaluates the field value when generating the SQL.

Query Filters and Navigation Properties - The Gotcha

This is the most common source of bugs with global query filters. When you load related entities using Include(), filters on the related entity can silently remove parent records due to INNER JOIN behavior.

The Problem

Consider this setup where Category → Products is a required relationship (the default):

modelBuilder.Entity<Category>().HasQueryFilter("SoftDelete", c => !c.IsDeleted);
modelBuilder.Entity<Product>().HasQueryFilter("SoftDelete", p => !p.IsDeleted);
modelBuilder.Entity<Product>()
.HasOne(p => p.Category)
.WithMany(c => c.Products)
.IsRequired();

Now compare these two queries:

// Query 1: Returns ALL products (filter only applies to Products)
var products = await context.Products.ToListAsync();
// Query 2: Returns FEWER products (Category filter causes INNER JOIN to drop rows)
var productsWithCategory = await context.Products
.Include(p => p.Category)
.ToListAsync();

Query 2 might return fewer results. Here’s why: because the navigation is required, EF Core uses an INNER JOIN to load categories. If a product’s category has IsDeleted = true, the category gets filtered out by its own query filter - and the INNER JOIN drops the product row too.

The Fix

You have two options:

Option 1: Make the navigation optional - Forces EF Core to use LEFT JOIN:

modelBuilder.Entity<Product>()
.HasOne(p => p.Category)
.WithMany(c => c.Products)
.IsRequired(false);

Option 2: Apply consistent filters on both sides - Ensure the child entity also filters by the parent’s criteria, so rows are never in an inconsistent state.

I recommend Option 2 for most cases. If both Product and Category have soft delete filters, a product should never reference a deleted category in normal operations.

Performance Best Practices

Global query filters add a WHERE clause to every query on filtered entities. Here’s how to make sure they don’t become a performance bottleneck.

Index Your Filtered Columns

This is the most important optimization. Since the filter condition runs on every query, the filtered columns must be indexed:

public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
// Index for soft delete queries
builder.HasIndex(p => p.IsDeleted);
// Composite index for multi-tenant + soft delete queries
builder.HasIndex(p => new { p.TenantId, p.IsDeleted });
}
}

Without these indexes, every query performs a full table scan on IsDeleted and TenantId - even simple FindAsync calls.

Parameterized Filters Are Efficient

When your filter references a field (like _tenantId), EF Core generates a parameterized query:

-- The @__tenantId_0 parameter changes per request, but the query plan is cached
SELECT p."Id", p."Name" FROM "Products" AS p
WHERE p."IsDeleted" = false AND p."TenantId" = @__tenantId_0

This means the database caches one query plan and reuses it for all tenants - no per-tenant compilation overhead.

Keep Filters Simple

Global filters should be simple comparisons (==, !=, !). Avoid:

  • Complex string operations (Contains, StartsWith)
  • Subqueries or method calls
  • Anything that won’t translate cleanly to SQL

Complex filters run on every query and can make your SQL optimizer give up on using indexes.

Common Mistakes to Avoid

1. Overwriting Filters Instead of Naming Them

// BUG: Second call overwrites the first
modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);
modelBuilder.Entity<Product>().HasQueryFilter(p => p.TenantId == _tenantId);
// Only TenantId filter is active!
// FIX: Use named filters
modelBuilder.Entity<Product>()
.HasQueryFilter("SoftDelete", p => !p.IsDeleted)
.HasQueryFilter("TenantIsolation", p => p.TenantId == _tenantId);

2. Forgetting Filters Apply to Include() and Navigation Properties

As we covered above, filters apply to Include() calls. If a related entity has a filter, INNER JOIN queries may silently drop parent rows. Always test queries that load navigation properties.

3. Raw SQL Bypasses Filters

If you use FromSqlRaw or FromSqlInterpolated, EF Core only applies global filters if the raw SQL is composable - meaning EF Core can wrap it in a subquery. Non-composable raw SQL (like stored procedures) ignores filters entirely:

// Composable - filter is applied
var products = await context.Products
.FromSqlRaw("SELECT * FROM \"Products\"")
.ToListAsync();
// Non-composable - filter is NOT applied
var products = await context.Products
.FromSqlRaw("EXEC GetProducts")
.ToListAsync();

4. Instance Methods in Filter Expressions

Filter expressions must translate to SQL. If you call an instance method that EF Core can’t translate, you’ll get a runtime exception:

// BAD: EF Core can't translate this to SQL
modelBuilder.Entity<Product>()
.HasQueryFilter(p => p.Name.Equals(GetDefaultName()));
// GOOD: Use simple property comparisons
modelBuilder.Entity<Product>()
.HasQueryFilter(p => !p.IsDeleted);

5. Filter Cycles

EF Core doesn’t detect circular filter references. If Entity A’s filter references Entity B, and Entity B’s filter references Entity A, you’ll get an infinite loop during query translation. Keep filters self-contained - reference only properties of the entity being filtered.

Troubleshooting Common Issues

”The LINQ expression could not be translated”

This happens when your filter expression contains logic that EF Core can’t convert to SQL. Stick to property comparisons and avoid calling C# methods inside the filter.

// Causes translation error
builder.HasQueryFilter(p => p.GetStatus() == "active");
// Fix: use a direct property comparison
builder.HasQueryFilter(p => p.IsActive);

Soft-Deleted Records Still Appearing in Queries

If deleted records show up after calling Remove(), verify that your SaveChangesAsync override is correctly intercepting EntityState.Deleted entries and changing them to EntityState.Modified with IsDeleted = true. Also confirm the entity actually has the query filter configured in OnModelCreating.

Include() Returns Fewer Results Than Expected

This is the navigation property gotcha. When using Include() with a required navigation, INNER JOIN behavior causes parent rows to disappear if the related entity is filtered out. Change the relationship to optional with IsRequired(false) or ensure consistent filters on both entities. See the EF Core query filters documentation for the full explanation.

Named Filter Not Being Applied

Ensure you’re on EF Core 10+ (Microsoft.EntityFrameworkCore version 10.0.0 or later). Named HasQueryFilter overloads are not available in earlier versions. Also verify that you’re not calling an unnamed HasQueryFilter after your named ones - unnamed calls overwrite all previous filters on that entity.

Filter Works in Development but Not in Production

If using a tenant service that reads from HttpContext, ensure IHttpContextAccessor is registered (builder.Services.AddHttpContextAccessor()) and that the DbContext is registered as scoped - not singleton. A singleton DbContext captures the tenant ID once and never updates it.

Limitations

Global query filters have a few hard limitations to be aware of:

  • Root entity types only - Filters can only be defined on the root entity in an inheritance hierarchy. You can’t apply different filters to derived types.
  • No cycle detection - EF Core won’t warn you about circular filter dependencies. You need to avoid these yourself.
  • All-or-nothing for unnamed filters - Without naming, IgnoreQueryFilters() removes everything. Always use named filters in EF Core 10 when you have multiple filters.

Key Takeaways

  • Global query filters let you define WHERE conditions once at the model level - EF Core applies them to every query automatically.
  • Soft delete and multi-tenancy are the two primary use cases. One missed filter in a multi-tenant app is a data leak.
  • Named query filters in EF Core 10 solve the biggest limitation - you can now define multiple independent filters per entity and disable them selectively with IgnoreQueryFilters(["FilterName"]).
  • Use interfaces (ISoftDelete, ITenantEntity) to apply filters consistently across all entities without repetitive configuration.
  • Index your filtered columns - IsDeleted and TenantId columns need indexes since the filter runs on every query.
  • Watch out for navigation properties - Include() with required navigations and query filters can silently drop rows due to INNER JOIN behavior.
What are global query filters in EF Core?

Global query filters are LINQ predicates defined at the model level in EF Core that automatically add WHERE clauses to every query for a specific entity type. They are configured using the HasQueryFilter method in OnModelCreating and are commonly used for soft delete and multi-tenancy scenarios.

How do I implement soft delete with global query filters?

Add an IsDeleted boolean property to your entity, then configure a global query filter with HasQueryFilter(p => !p.IsDeleted) in OnModelCreating. Override SaveChangesAsync to intercept delete operations and set IsDeleted to true instead of physically removing the record. All queries will automatically exclude deleted records.

How do global query filters work with multi-tenancy?

Store the current tenant ID in a field on your DbContext (typically resolved from the HTTP request via an injected service). Reference that field in the query filter expression like HasQueryFilter(p => p.TenantId == _tenantId). EF Core generates a parameterized query that automatically filters by the current tenant on every query.

How do I disable a global query filter for a specific query?

Use the IgnoreQueryFilters() extension method on your LINQ query. In EF Core 10, you can selectively disable specific named filters by passing their names: IgnoreQueryFilters(["SoftDelete"]) disables only the soft delete filter while keeping others like tenant isolation active.

What are named query filters in EF Core 10?

Named query filters are a feature introduced in EF Core 10 that lets you define multiple independent filters per entity by giving each filter a unique name. This solves the pre-EF-10 limitation where calling HasQueryFilter twice on the same entity would overwrite the first filter. Named filters can also be selectively disabled using IgnoreQueryFilters with specific filter names.

Do global query filters affect performance?

Global filters add a WHERE clause to every query on the filtered entity, so the filtered columns (like IsDeleted and TenantId) must be indexed. When filters reference a DbContext field, EF Core uses parameterized queries, which allows the database to cache and reuse query plans efficiently. With proper indexing, the performance impact is minimal.

Can I apply a global query filter to all entities that implement an interface?

Yes. Define an interface like ISoftDelete with the IsDeleted property, implement it on all relevant entities, then loop through modelBuilder.Model.GetEntityTypes() in OnModelCreating. For each entity type that implements the interface, use Expression trees to build the filter lambda dynamically and call SetQueryFilter.

What are the limitations of global query filters in EF Core?

Filters can only be defined on root entity types in an inheritance hierarchy, not on derived types. EF Core does not detect circular filter references, which can cause infinite loops. Without naming, IgnoreQueryFilters removes all filters at once. Additionally, non-composable raw SQL queries (like stored procedures) bypass global filters entirely.

Summary

Global query filters are one of those features that seem simple but fundamentally change how you build data access layers. Define the filter once, and every query across your entire application respects it automatically. No more forgotten WHERE clauses, no more tenant data leaks, no more manual soft-delete checks.

With EF Core 10’s named query filters, the feature is now mature enough for complex scenarios - multiple independent filters per entity with granular control over which ones to disable. If you’re still combining all your filter conditions into a single HasQueryFilter call, it’s time to upgrade.

If you found this helpful, share it with your colleagues - and if there’s a topic you’d like to see covered next, drop a comment and let me know.

Happy Coding :)

What's your Feedback?
Do let me know your thoughts around this article.

8,200+ .NET devs get this every Tuesday

Free weekly newsletter

Stay ahead in .NET

Tutorials Architecture DevOps AI

Once-weekly email. Best insights. No fluff.

Join 8,200+ developers · Delivered every Tuesday