Centralized Data Boundary Enforcement in EF Core
Global query filters let you inject a persistent WHERE clause into every EF Core query for an entity, enabling soft-delete, tenant isolation, and row-level security without repetitive coding.
09 Dec 2025, 00:56 UTC

The problem of scattered data filters
Every project that works with relational data eventually needs to ensure that certain rows are invisible to most queries. Common requirements include hiding deleted records, restricting results to the current tenant, or enforcing row-level security. Without a centralized mechanism, developers add .Where() clauses manually to every LINQ query. This pattern is easy to forget, hard to audit, and creates inconsistencies when ad-hoc reports or bulk operations bypass the application logic.
How global query filters work under the hood
A global query filter is a LINQ expression that EF Core attaches to an entity type's model during OnModelCreating. When any query for that entity executes, the query translator prepends the filter's WHERE clause to the generated SQL. The filter is compiled into the model's query pipeline, meaning it applies regardless of where the query originates—whether from application code, a repository abstraction, or a generated report. This behavior is intentional: the filter enforces a data boundary at the ORM layer, not just at the application logic layer. Terms such as query translator and compiled model refer to EF Core's internal pipeline that converts LINQ expressions into SQL before the command is sent to the database.
Smallest suitable design
Reach for a global query filter when the boundary is universal and rarely changes per query. Soft-delete is the classic example: every query for an Order entity should exclude rows where IsDeleted is true, unless you explicitly bypass the filter. Multi-tenant applications often use a TenantId filter to ensure that a context configured for tenant A never materializes rows belonging to tenant B. If your boundary is situational—e.g., a one-off reporting query that must see all rows—you'll want to design the filter as overridable from the start, rather than retrofitting it later.
Configuring the filter
EF Core supports two syntaxes. The Fluent API works everywhere:
modelBuilder.Entity().HasQueryFilter(o => !o.IsDeleted);The [QueryFilter] attribute was introduced in EF Core 5 and offers a declarative alternative, keeping the configuration adjacent to the entity definition:
public class Order { public int Id { get; set; } public bool IsDeleted { get; set; } }The Fluent API approach is more explicit and allows per-entity customization, while the attribute keeps the configuration co-located with the model. Both compile the expression into the model; the choice depends on team convention and whether you need to apply the filter to multiple entities sharing a base class.
Trust data boundaries and tenant scoping
When a filter enforces tenant isolation, the TenantId value must be present in the current DbContext's change tracker or query state. A common pattern sets the current tenant in DbContext overrides or via a DbInterception hook, ensuring that the filter's parameter binds to the active tenant value at query time. If the tenant context is missing, the filter may bind to null or throw, depending on the expression shape. This is where the trust boundary conversation happens: the ORM enforces the rule, but the application must ensure the tenant identifier is correctly wired.
Operational checks diagnosing filter impact
To verify that a filter is active and translated as expected, enable command logging and inspect the generated SQL:
context.Database.Log = sql => Console.WriteLine(sql);
using var ctx = new MyDbContext(...);
var active = ctx.Orders.ToList(); // Observe the WHERE clauseThe output will include something like WHERE IsDeleted = 0 AND ... (or = 1 depending on convention). If the filter does not appear, confirm that the context instance used for the query is the same one whose model was built with the filter, or that the model has not been rebuilt after a configuration change.
For a deeper check, compile a LINQ query and inspect the expression tree:
var compiled = ctx.Orders.Where(o => true).Compile();
// Examine the tree; the filter should appear as an additional AND node beneath the root expression.After modifying a filter expression, rebuild the model by calling context.Model.GetEntityType().Filter or by calling context.Database.EnsureCreated() followed by a fresh query. Failure to rebuild means the old filter expression may still be in effect, leading to unexpected SQL.
Limitations and when the design shifts
Global filters always contribute to the SQL WHERE clause. They cannot be selectively disabled per-query without using AsNoQueryFilter() or IgnoreQueryFilters(), which bypass the filter for that specific invocation. Ad-hoc reporting scripts that expect unfiltered data may silently return fewer rows, and bulk update operations such as ExecuteUpdate respect the filter unless explicitly overridden. Complex filter expressions involving non-sargable functions or correlated subqueries can degrade performance because the filter is applied to every query against the entity, even those where the condition is irrelevant.
Practical verification checklist
- Enable command logging. Run the app with DbLoggerCategory.Database.Command logging enabled and execute a simple read. Confirm the filter's WHERE term appears in the emitted SQL.
- Compile a query. Use .Compile() on a LINQ expression and inspect the expression tree to verify the filter is injected at the expected expression node.
- Rebuild after changes. After modifying a filter, rebuild the model or call context.Database.EnsureCreated() and re-run the check to observe behavioral changes in subsequent queries.
- Test a bypass. Use IgnoreQueryFilters() to confirm the filter is indeed the only mechanism preventing unfiltered reads.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.