Skip to main content
SEMastery
Data Accessintermediate

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.

12 min readUpdated February 4, 2026

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.

Without filters you repeat the same rule; with filters EF Core adds it for you.

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

You write query
EF adds filter
SQL built
DB returns rows

Steps

1

You write query

context.Blogs.ToList()

2

EF adds filter

appends WHERE IsDeleted = 0

3

SQL built

full SQL sent to DB

4

DB returns rows

only non-deleted rows

Every query for the entity passes through the filter before hitting the database.

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.

One database holds many tenants; the filter keeps each tenant in its own lane.

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.

SituationWhat to useResult
Normal app readsJust query normallyFilter applied automatically
Admin needs deleted rowsIgnoreQueryFilters()Filter skipped for that query
Need all tenants (reporting)IgnoreQueryFilters()Tenant rule skipped
Restore a soft-deleted rowIgnoreQueryFilters() then updateRow 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.

Named filters stack on one entity, each can be controlled on its own.

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

Need to bypass a filter
All or one?
IgnoreQueryFilters()
IgnoreQueryFilters([name])

Steps

1

Need to bypass a filter

e.g. admin or report view

2

All or one?

decide the scope

3

IgnoreQueryFilters()

removes every filter

4

IgnoreQueryFilters([name])

removes only that one

Pick between turning off everything or only one named filter.

Here is a small comparison of the old and new behaviour.

FeatureBefore EF Core 10EF Core 10 and later
Filters per entityOnly oneAs many as you want
Combine rulesManual && in one expressionSeparate named filters
Disable all filtersIgnoreQueryFilters()IgnoreQueryFilters() (same)
Disable one filterNot possible cleanlyIgnoreQueryFilters(["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.

The full life of a filtered query, from model setup to result.

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.

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 OnModelCreating using HasQueryFilter.
  • 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

Related Posts