How To Use Global Query Filters in EF Core

If you have worked on any real database-backed application, you already know the pain of repeating the same WHERE condition across dozens of queries. Two situations come up again and again: soft delete, where you never want to return rows marked as deleted, and multi-tenancy, where every query needs to be scoped to a tenant ID. EF Core gives you a clean way to handle both cases without sprinkling the same condition everywhere, and that feature is called Global Query Filters.

This article walks through how query filters work, how to apply them, when to bypass them, and a few production gotchas that catch people off guard the first time they use this feature.

The problem without query filters

Consider an Order entity that supports soft delete using an IsDeleted flag instead of physically removing the row.

public class Order
{
    public int Id { get; set; }
    public bool IsDeleted { get; set; }
}

This is a plain POCO, nothing EF-specific about it yet. The business rule is simple: never return an order that has been soft-deleted. Without a query filter, you have to remember to add that check every single time you query the Orders table.

dbContext
    .Orders
    .Where(order => !order.IsDeleted)
    .Where(order => order.Id == orderId)
    .FirstOrDefault();

This query works fine on its own. The trouble is that it only works if every developer on the team remembers to add the IsDeleted check, in every place the Orders table is queried. Miss it once in a report, an export job, or a new controller action, and you have a soft-deleted order showing up where it should not. This is exactly the kind of bug that slips through code review because the query looks perfectly reasonable by itself.

Applying a global query filter with HasQueryFilter

EF Core lets you define this condition once, on the entity configuration, and have it applied automatically to every query against that entity. You do this inside OnModelCreating using the HasQueryFilter method.

modelBuilder
    .Entity<Order>()
    .HasQueryFilter(order => !order.IsDeleted);

This tells EF Core to append the !order.IsDeleted condition to every LINQ query that touches the Order entity, without you having to write it explicitly. The filter is a lambda expression, so it can reference anything available in the DbContext, including a tenant ID resolved from a scoped service, which is exactly how most multi-tenant EF Core applications enforce data isolation.

With the filter in place, the earlier query can be simplified because the soft-delete check no longer needs to be written by hand.

dbContext
    .Orders
    .Where(order => order.Id == orderId)
    .FirstOrDefault();

EF Core still adds the IsDeleted check under the hood. You can confirm this by looking at the generated SQL, which includes both conditions even though only one was written in the LINQ query.

SELECT o.*
FROM Orders o
WHERE o.IsDeleted = FALSE AND o.Id = @orderId

Notice the filter condition comes first in the generated WHERE clause. This matters for indexing: if IsDeleted (or a tenant ID column) is part of every query on this table, it is a strong candidate for a composite index alongside your primary lookup columns. Skipping this is one of the more common performance mistakes teams make after adopting query filters, the queries look identical to before but now carry an extra predicate that was never accounted for in the index design.

Bypassing filters with IgnoreQueryFilters

There are legitimate cases where you need to see the full picture, including soft-deleted rows or data belonging to other tenants. An admin dashboard, an audit report, or a data-recovery tool are typical examples. EF Core gives you an explicit escape hatch for this.

dbContext
    .Orders
    .IgnoreQueryFilters()
    .Where(order => order.Id == orderId)
    .FirstOrDefault();

Calling IgnoreQueryFilters removes every configured filter for that entity in this particular query, not just the one you had in mind. This is the detail that trips people up in multi-tenant systems: if you add IgnoreQueryFilters to peek past a soft-delete flag, you may also accidentally disable the tenant isolation filter on the same call, and now your query can return another tenant’s data. Treat this method as something you reach for deliberately in a handful of well-reviewed places, not something you sprinkle around to work around a filter that is getting in the way.

Things to know before relying on query filters

A few behaviors are not obvious until you hit them in a real project, and they are worth knowing before you build a data-access layer around this feature.

Only one HasQueryFilter call is allowed per entity type. If you configure it more than once for the same entity, the last call wins and silently replaces the earlier one, EF Core does not throw an error or merge the two. If your entity needs more than one condition, for example both a soft-delete check and a tenant check, combine them into a single expression using the logical AND operator.

modelBuilder
    .Entity<Order>()
    .HasQueryFilter(order => !order.IsDeleted && order.TenantId == _tenantId);

The second gotcha follows from the first: since only one filter exists per entity, you cannot selectively ignore just the tenant check while keeping the soft-delete check active, or vice versa. IgnoreQueryFilters turns off the whole filter. If you need partial control, the practical workaround is to call IgnoreQueryFilters to clear everything, then manually re-apply the specific condition you still want in that query.

dbContext
    .Orders
    .IgnoreQueryFilters()
    .Where(order => !order.IsDeleted)
    .Where(order => order.Id == orderId)
    .FirstOrDefault();

This pattern keeps the soft-delete rule intact for an admin query that still needs to bypass tenant scoping, for instance. It is a bit more verbose, but it is explicit, and explicit code is easier to review than a filter you have to trust blindly.

Where this fits in a larger design

Global query filters solve the read side of soft delete and multi-tenancy well, but they are not a complete solution on their own. On the write side, you still need something to set IsDeleted to true instead of letting a plain Remove call issue a DELETE statement, and a SaveChanges interceptor is the usual place to do that centrally rather than repeating it in every repository method.

It is also worth remembering that query filters apply at the LINQ level, so raw SQL executed through FromSqlRaw or ExecuteSqlRaw does not automatically pick up the filter condition. If a piece of your codebase drops down to raw SQL for performance reasons, you are back to writing the IsDeleted or tenant check by hand in that specific spot, and it is easy to forget this the day someone adds a new raw query months later. Keep a checklist or a code review note for this if raw SQL is used anywhere near tables that carry query filters.

For teams moving to EF Core 7 or later, it is worth checking whether newer bulk operations like ExecuteUpdate and ExecuteDelete are used anywhere on entities with query filters, and testing that behavior explicitly, since bulk operations bypass the normal change-tracking pipeline and deserve their own verification rather than assuming they behave identically to a tracked query.

Used correctly, query filters remove an entire category of repetitive, error-prone code from a codebase. Used carelessly, particularly around IgnoreQueryFilters, they can quietly turn into a data-isolation bug. Treat the filter definition as a piece of security-sensitive configuration in multi-tenant systems, and review any place that disables it with the same care you would give to a raw SQL query.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading