Global Query Filters in EF Core: Soft Delete and Multi-Tenancy Made Easy
Learn EF Core global query filters with simple examples for soft delete and multi-tenancy, plus the new named filters in .NET 10.
Global Query Filters in EF Core: Soft Delete and Multi-Tenancy Made Easy
Imagine you run a small library in your town. Every morning you tell your helper one simple rule: "Only show readers the books that are still on the shelf. Do not show the lost or damaged ones." You say this once. After that, your helper remembers it for the whole day. Every time someone asks for books, the helper quietly hides the lost ones without you repeating yourself.
A global query filter in EF Core works exactly like that one rule you give in the morning. You set it up once. After that, EF Core remembers it and adds it to every database query for that table, all on its own.
In this post we will learn what these filters are, why they save you a lot of trouble, how to write them, and the new things that arrived in EF Core 10 (which ships with .NET 10, the current LTS release). The language is kept simple on purpose. If you know a little C# and a little EF Core, you are ready.
What problem does it solve?
Let us start with two very common needs in real apps.
1. Soft delete. Many apps do not really delete rows. Instead they mark a row as deleted with a column like IsDeleted. The row stays in the table, but users should never see it. This is called soft delete. It is useful because you can recover the data later, and you keep a history.
2. Multi-tenancy. Some apps serve many companies (called tenants) from one database. Company A must never see Company B's data. So every query must say "only rows where TenantId equals my company".
Without global filters, you would have to write the same Where clause again and again, in every single query:
// Repeated everywhere - easy to forget one!
var blogs = await context.Blogs
.Where(b => !b.IsDeleted && b.TenantId == currentTenantId)
.ToListAsync();
var posts = await context.Posts
.Where(p => !p.IsDeleted && p.TenantId == currentTenantId)
.ToListAsync();The danger is clear. The day you forget one Where, a deleted row shows up, or worse, one company sees another company's data. That is a real bug that can hurt people.
Global query filters fix this by moving the rule to one place: the model setup. You write it once, and EF Core applies it automatically forever.
A real-life everyday analogy
Think about a chaiwala (tea seller) at a busy railway station. He has one standing rule: "Serve tea only in clean cups." He does not shout this rule for every customer. He told his helper once in the morning. Now, no matter how many people come, only clean cups go out.
The standing rule is the global filter. The helper is EF Core. The customers are your queries. You set the rule once, and every order follows it without extra effort.
How to set up a basic filter
Filters live in your DbContext, inside the OnModelCreating method. You use the HasQueryFilter method on an entity.
Here is a simple soft-delete filter for a Blog entity:
public class AppDbContext : DbContext
{
public DbSet<Blog> Blogs => Set<Blog>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// One rule: never return deleted blogs.
modelBuilder.Entity<Blog>()
.HasQueryFilter(b => !b.IsDeleted);
}
}That is it. Now this query:
var blogs = await context.Blogs.ToListAsync();...automatically becomes "give me blogs where IsDeleted is false". You did not write any Where. EF Core added it for you behind the scenes.
The flow below shows what happens each time you run a query.
How EF Core applies a filter
Steps
You write query
context.Blogs.ToList()
EF adds filter
appends WHERE IsDeleted = 0
SQL built
full SQL sent to DB
DB returns rows
only non-deleted rows
Multi-tenancy with a captured variable
A very neat trick is that a filter can use a variable from your context. This is perfect for multi-tenancy. You read the current tenant once (for example from the logged-in user), store it in a field, and use that field in the filter.
public class AppDbContext : DbContext
{
private readonly Guid _tenantId;
public AppDbContext(Guid tenantId)
{
_tenantId = tenantId;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>()
.HasQueryFilter(b => b.TenantId == _tenantId);
}
}Now every blog query is locked to the current tenant. Company A simply cannot see Company B's rows, even if a developer forgets to add the rule. EF Core watches the _tenantId field and uses its current value for each query.
Turning a filter off for one query
Sometimes you really do need the hidden rows. An admin page might need to show deleted blogs so they can be restored. For this, EF Core gives you IgnoreQueryFilters().
// Admin view: include deleted blogs too.
var allBlogs = await context.Blogs
.IgnoreQueryFilters()
.ToListAsync();This single query ignores the filter. All other queries still keep it. The filter is not removed; it is only skipped for this one call. Think of it as the chaiwala saying, "for this one special guest, use the gold cup instead."
Here is a quick table of when to use which.
| Situation | What to use | Result |
|---|---|---|
| Normal app reads | Just query normally | Filter applied automatically |
| Admin needs deleted rows | IgnoreQueryFilters() | Filter skipped for that query |
| Need all tenants (reporting) | IgnoreQueryFilters() | Tenant rule skipped |
| Restore a soft-deleted row | IgnoreQueryFilters() then update | Row found and brought back |
The big change in EF Core 10: named filters
For a long time, EF Core had one painful limit: only one filter per entity type. If you called HasQueryFilter twice on the same entity, the second call simply replaced the first one. So if you needed both soft delete and multi-tenancy, you had to glue them into one big expression:
// Old style: combine everything into a single filter.
modelBuilder.Entity<Blog>()
.HasQueryFilter(b => !b.IsDeleted && b.TenantId == _tenantId);This worked, but it had a downside. You could not turn off just one part. If you called IgnoreQueryFilters(), you lost both rules at once. Maybe you wanted deleted rows but still wanted to stay inside your tenant. The old way could not do that cleanly.
EF Core 10 fixes this with named query filters. Now you can give each filter its own name and add as many as you like:
modelBuilder.Entity<Blog>()
.HasQueryFilter("SoftDeletionFilter", b => !b.IsDeleted)
.HasQueryFilter("TenantFilter", b => b.TenantId == _tenantId);Two separate filters now live on the same entity, each with a clear name. EF Core combines them when building SQL, so by default both rules apply.
Turning off just one named filter
The best part of named filters is selective control. You can disable only the filter you choose, by passing its name (or a list of names) to IgnoreQueryFilters.
// Show deleted blogs, but STAY inside the current tenant.
var deletedForTenant = await context.Blogs
.IgnoreQueryFilters(["SoftDeletionFilter"])
.ToListAsync();Here the soft-delete rule is turned off, so deleted blogs appear. But the tenant rule is still on, so you never leak another company's data. This was hard before EF Core 10 and is now one short line.
If you call IgnoreQueryFilters() with no arguments, it still turns off all filters on the entity, just like before. So you have both options.
Choosing how to ignore filters
Steps
Need to bypass a filter
e.g. admin or report view
All or one?
decide the scope
IgnoreQueryFilters()
removes every filter
IgnoreQueryFilters([name])
removes only that one
Here is a small comparison of the old and new behaviour.
| Feature | Before EF Core 10 | EF Core 10 and later |
|---|---|---|
| Filters per entity | Only one | As many as you want |
| Combine rules | Manual && in one expression | Separate named filters |
| Disable all filters | IgnoreQueryFilters() | IgnoreQueryFilters() (same) |
| Disable one filter | Not possible cleanly | IgnoreQueryFilters(["Name"]) |
Things to watch out for
Global filters are powerful, but a few rules keep you safe.
Filters and required relationships. If a Post requires a Blog, and a filter hides some blogs, EF Core may also hide posts whose blog is filtered out. This is by design, but it can surprise you. Make navigations optional when soft delete is involved, or apply the same filter to both sides.
Filters apply at the root, not inside Include. A filter set on Blog is applied when you query blogs. Be careful when loading related data, because the behaviour around includes can differ. Test your includes to be sure you see what you expect.
Use the same field consistently. For multi-tenancy, read the tenant id once and store it. Do not change it mid-request, or different queries in the same unit of work could use different values.
Performance. A filter adds a WHERE to your SQL. Make sure the filtered columns (IsDeleted, TenantId) have indexes. Otherwise the database may scan the whole table.
The diagram below sums up the safe path from setup to query.
A complete small example
Let us tie it all together with one tidy context that uses both filters by name.
public class Blog
{
public int Id { get; set; }
public string Title { get; set; } = "";
public bool IsDeleted { get; set; }
public Guid TenantId { get; set; }
}
public class AppDbContext : DbContext
{
private readonly Guid _tenantId;
public AppDbContext(DbContextOptions<AppDbContext> options, Guid tenantId)
: base(options)
{
_tenantId = tenantId;
}
public DbSet<Blog> Blogs => Set<Blog>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>()
.HasQueryFilter("SoftDeletionFilter", b => !b.IsDeleted)
.HasQueryFilter("TenantFilter", b => b.TenantId == _tenantId);
}
}With this in place:
context.Blogs.ToListAsync()returns only this tenant's non-deleted blogs.context.Blogs.IgnoreQueryFilters(["SoftDeletionFilter"]).ToListAsync()returns this tenant's blogs including deleted ones.context.Blogs.IgnoreQueryFilters().ToListAsync()returns everything, across all tenants and including deleted rows. Use this one with great care.
A note on related tools
You may use libraries that build on top of EF Core for these patterns. A quick, honest heads-up: some popular .NET libraries that used to be free have moved to a commercial license. For example, MediatR and MassTransit now require a paid license for many uses. Global query filters themselves are a built-in EF Core feature and are free, so you do not need any paid library just to use them. But if you wire filters into a larger pipeline, check the licenses of the helper libraries you add.
Quick recap
- A global query filter is a rule you set once on an entity. EF Core then adds it to every query for that entity automatically.
- The two classic uses are soft delete (hide rows marked deleted) and multi-tenancy (keep each company's data separate).
- You set filters in
OnModelCreatingusingHasQueryFilter. - Filters can use a field from your context, which makes multi-tenancy easy.
- Use
IgnoreQueryFilters()to skip filters for one query, for example on an admin screen. - EF Core 10 brings named filters: many filters per entity, each with a name.
- With names, you can turn off just one filter using
IgnoreQueryFilters(["FilterName"]), while keeping the rest active. - Watch out for required relationships, includes, and indexes on your filtered columns.
- The feature is built into EF Core and free; only some external helper libraries (like MediatR, MassTransit) are now commercially licensed.
References and further reading
- Global Query Filters - EF Core (Microsoft Learn)
- How To Use Global Query Filters in EF Core - Milan Jovanovic
- Named Query Filters in EF 10 - Milan Jovanovic
- Named global query filters in Entity Framework Core 10 - Tim Deschryver
- Global Query Filters in EF Core - codewithmukesh
- The Fluent API HasQueryFilter Method - Learn Entity Framework Core
Related Posts
How to Use Global Query Filters in EF Core (Beginner Guide)
Learn EF Core global query filters with simple examples for soft delete and multi-tenancy, plus the new named filters in EF Core 10.
How to Use EF Core Interceptors: A Beginner-Friendly Guide
Learn EF Core interceptors step by step. Add auditing, soft delete, logging, and timing to your DbContext with clean, reusable code and zero clutter.
Understanding Change Tracking for Better Performance in EF Core
Learn how EF Core change tracking works, the entity states it uses, and simple tricks like AsNoTracking to make your .NET apps faster.
Calling Views, Stored Procedures and Functions in EF Core
A friendly, beginner guide to calling database views, stored procedures, and functions in EF Core with FromSql, SqlQuery, ExecuteSql, and ToView.
EF Core Bulk Insert: Boost Performance with Entity Framework Extensions
Learn how EF Core bulk insert with Entity Framework Extensions saves data faster, using simple examples, diagrams, and clear performance comparisons.
Getting Started With MongoDB in EF Core: A Beginner's Guide
A friendly beginner guide to using MongoDB with EF Core in .NET. Learn setup, DbContext, UseMongoDB, CRUD, mapping, and the limits you must know.