Skip to main content
SEMastery
Data Accessbeginner

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.

11 min readUpdated May 20, 2026

How to Use Global Query Filters in EF Core

Imagine you work at a busy sweet shop. Every morning your manager gives you one standing rule: "Never put expired sweets on the shelf." You do not check this rule again for every single box. It just becomes part of how you work. Whatever box you pick up, you quietly skip the expired ones.

A global query filter in EF Core is exactly this kind of standing rule. You write it once. After that, EF Core quietly applies it to every query for that table, without you typing the condition again and again.

In this guide you will learn what these filters are, why they save you from painful bugs, how to write them, and how the new named filters in EF Core 10 make them even better. We will keep the language simple and the examples small.

What is a global query filter?

A global query filter is a small piece of C# logic that you attach to an entity type. EF Core then adds it as a WHERE clause to every SQL query it builds for that entity.

You configure it once inside OnModelCreating in your DbContext. After that, every ToList, every FirstOrDefault, every Include, and every navigation load for that entity carries the filter automatically.

A filter you write once is added to every query EF Core builds.

The big idea is safety by default. Instead of trusting every developer to remember a condition, the filter is baked into the model. You cannot forget it, because you never type it.

Two everyday problems it solves

Most teams reach for global query filters to solve two common problems. Let us look at both in plain words.

ProblemWithout filtersWith a global filter
Soft deleteEvery query must add WHERE IsDeleted = 0 by handThe filter hides deleted rows for you
Multi-tenancyEvery query must add WHERE TenantId = @id by handThe filter limits rows to the current tenant

Soft delete

"Soft delete" means you do not really erase a row. You just mark it as deleted by setting a flag, like IsDeleted = true. The data stays in the table, so you can recover it later or keep an audit trail. But normal screens should never show those rows.

Multi-tenancy

A "multi-tenant" app serves many customers (tenants) from one database. Customer A must never see Customer B's data. A filter on TenantId keeps each customer locked inside their own rows.

Why filters beat manual conditions

One rule
Every query
Fewer bugs
Safer data

Steps

1

One rule

Write filter once in DbContext

2

Every query

EF Core applies it automatically

3

Fewer bugs

Nobody forgets the WHERE clause

4

Safer data

Deleted or other-tenant rows stay hidden

A single rule protects every query in the app.

Your first filter: soft delete

Let us build a tiny example. We have a Product entity with an IsDeleted flag. We want every query to skip deleted products.

First, the entity:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public decimal Price { get; set; }
    public bool IsDeleted { get; set; }
}

Now we add the filter inside OnModelCreating. The method is called HasQueryFilter, and we give it a small lambda that returns true for the rows we want to keep:

public class ShopDbContext : DbContext
{
    public DbSet<Product> Products => Set<Product>();
 
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>()
            .HasQueryFilter(p => !p.IsDeleted);
    }
}

That single line changes everything. Now this normal query:

var products = await context.Products.ToListAsync();

...produces SQL that already includes WHERE [p].[IsDeleted] = 0. You did not write that condition. EF Core added it for you. Deleted products simply do not appear.

The lambda reads as "keep the product when it is not deleted". The ! means "not". So !p.IsDeleted is true only when IsDeleted is false.

The same filter hides deleted rows across the whole app.

Filters follow your relationships too

Here is a detail that surprises many beginners. Filters do not only apply when you query an entity directly. They also apply when EF Core loads that entity through a navigation property.

Say an Order has many Product items. When you write Include(o => o.Products), the soft-delete filter on Product still runs. Deleted products are skipped inside the included list as well. This keeps your data consistent everywhere, not just on the main query.

That is powerful, but it means you should keep filters simple and predictable. A heavy or confusing filter spreads its cost to every related load too.

Turning a filter off on purpose

Sometimes you genuinely need the hidden rows. An admin "recycle bin" screen, a report, or a cleanup job may need to see deleted products. For these cases EF Core gives you IgnoreQueryFilters.

var allProducts = await context.Products
    .IgnoreQueryFilters()
    .ToListAsync();

This query ignores all filters on that entity and returns every row, deleted or not. Use it carefully and only where you truly mean it. In a multi-tenant app especially, switching filters off can leak data, so treat IgnoreQueryFilters as a sharp tool.

When to ignore filters

Normal screen
Admin tool
Cleanup job
Report

Steps

1

Normal screen

Filters ON, hide deleted rows

2

Admin tool

Filters OFF to show recycle bin

3

Cleanup job

Filters OFF to purge old rows

4

Report

Decide per query, be careful

Most queries keep filters on; a few special ones turn them off.

Adding multi-tenancy to the mix

Now imagine our shop app serves many stores. Each Product belongs to a store through a TenantId. We want each store to see only its own products.

The classic trick is to read the current tenant id from a service, then use it inside the filter. EF Core captures the value when it builds the model query.

public class ShopDbContext : DbContext
{
    private readonly ITenantProvider _tenant;
 
    public ShopDbContext(DbContextOptions options, ITenantProvider tenant)
        : base(options)
    {
        _tenant = tenant;
    }
 
    public DbSet<Product> Products => Set<Product>();
 
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>()
            .HasQueryFilter(p =>
                !p.IsDeleted &&
                p.TenantId == _tenant.CurrentTenantId);
    }
}

Notice how we joined two conditions with && ("and"). Before EF Core 10 this was the only way to have more than one rule on the same entity: stuff them all into one big lambda.

That worked, but it had a real weakness. Because both rules lived in one filter, IgnoreQueryFilters was all-or-nothing. If an admin screen wanted to see deleted rows, it had to switch off the tenant rule too, which is dangerous. You could not say "ignore only the soft-delete part".

EF Core 10: named query filters

EF Core 10 fixes exactly this problem. You can now give each filter a name and attach several filters to the same entity. Each one stands on its own.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>()
        .HasQueryFilter("SoftDelete", p => !p.IsDeleted)
        .HasQueryFilter("Tenant", p => p.TenantId == _tenant.CurrentTenantId);
}

Now the two rules are separate. EF Core still combines them with "and" when it runs queries, so by default you get both. But you can manage them one at a time.

To turn off just one filter, pass its name to IgnoreQueryFilters:

// Show deleted products, but STILL stay inside the current tenant.
var deletedForThisStore = await context.Products
    .IgnoreQueryFilters(new[] { "SoftDelete" })
    .ToListAsync();

This is the part that makes named filters so valuable. The tenant rule stays on, so you cannot accidentally see another store's data, while the soft-delete rule is lifted for this one query. That is much safer than the old all-or-nothing approach.

Named filters let you switch one rule off while keeping the rest.

Here is a quick comparison of the old style and the new style.

FeatureBefore EF Core 10EF Core 10 named filters
Filters per entityOne filter, combined with &&Many filters, each named
Disable one ruleNot possibleIgnoreQueryFilters(new[] { "Name" })
Disable everythingIgnoreQueryFilters()IgnoreQueryFilters() still works
ReadabilityOne long lambdaShort, separate rules

A small but important rule about parameters

When a filter uses a value like _tenant.CurrentTenantId, EF Core reads that value at the moment it first builds the query plan and turns it into a SQL parameter. Each time the tenant changes between requests, EF Core uses the new value, because a fresh DbContext is created per request in most web apps.

This is why you should create a new DbContext for each web request (the default in ASP.NET Core dependency injection). If you reuse one long-lived context across tenants, the filter could capture a stale value. Short-lived contexts keep filters correct and predictable.

Save changes can still skip the flag

A filter only changes how you read data. It does not change how you write it. So for soft delete you still need to actually set IsDeleted = true instead of removing the row.

A common pattern is to override SaveChanges and turn deletes into flag updates:

public override int SaveChanges()
{
    foreach (var entry in ChangeTracker.Entries<Product>())
    {
        if (entry.State == EntityState.Deleted)
        {
            entry.State = EntityState.Modified;
            entry.Entity.IsDeleted = true;
        }
    }
    return base.SaveChanges();
}

Now when your code calls Remove(product) and then SaveChanges, the row is not truly deleted. It is just flagged. And because of your read filter, it disappears from normal queries. The two pieces work together: the override hides the row on write-time, the filter hides it on read-time.

How the whole flow fits together

Let us put the moving parts in order so the picture is clear.

End-to-end soft delete with filters

Remove + Save
Flag set
Normal read
Admin read

Steps

1

Remove + Save

SaveChanges flips IsDeleted to true

2

Flag set

Row stays in the table

3

Normal read

Filter hides flagged rows

4

Admin read

IgnoreQueryFilters shows them

Write sets the flag, read hides the row, admin can peek.

Common mistakes to avoid

Filters are friendly, but a few traps catch beginners. Keep these in mind.

  • Forgetting filters apply to includes. If a related list looks shorter than expected, a filter on the child entity may be doing its job. That is usually correct, not a bug.
  • Heavy logic in a filter. The filter runs on every query, so keep it cheap. Avoid calling methods EF Core cannot translate to SQL.
  • Relying on IgnoreQueryFilters too often. If half your code turns filters off, the filter is no longer protecting you. Reach for it only in clearly named, special cases.
  • Mixing required navigations with filters. A filter that hides a parent row can make a required relationship look broken. Test these cases.
  • Assuming filters change writes. They do not. You still set flags or tenant ids yourself when saving.

When not to use a global filter

Filters are great for rules that apply to almost every query. They are a poor fit for one-off conditions. If only one screen needs a condition, just add a normal Where clause there. Save global filters for the rules that must hold true everywhere, like soft delete and tenant isolation. Using them for narrow cases makes your queries confusing, because the WHERE clause is hidden from the reader.

Quick recap

  • A global query filter is a rule you write once that EF Core adds to every query for an entity.
  • Configure it in OnModelCreating with HasQueryFilter.
  • The two classic uses are soft delete (!p.IsDeleted) and multi-tenancy (p.TenantId == currentTenant).
  • Filters also apply to Include and navigation loads, keeping data consistent.
  • Use IgnoreQueryFilters() to switch filters off for special queries like admin tools.
  • EF Core 10 adds named filters, so you can have many filters per entity and disable just one with IgnoreQueryFilters(new[] { "Name" }).
  • Filters only change reads. For soft delete you still set the flag yourself, often by overriding SaveChanges.
  • Use a fresh DbContext per request so parameter values like the tenant id stay correct.

References and further reading

Related Posts