Ever wished you could flip a single switch and have every query in your application automatically skip soft‑deleted rows, respect tenant isolation, or hide draft content—without sprinkling Where(...)
clauses all over the place? That’s exactly what EF Core’s Global Query Filters do. In this article I’ll show you how to wield them like a pro, from hello‑world setup to edge‑case wizardry—plus interceptors, shadow‑property audits, and other advanced moves seasoned .NET devs swear by.
The 60‑Second Primer — What Are Global Query Filters?
Global filters are LINQ predicates that EF Core stitches into every generated SQL statement for the targeted entity types. Think of them as stealth middleware at the LINQ → SQL translation layer:
SELECT ... FROM Posts AS p
WHERE p.IsDeleted = 0 -- <- your filter, injected automatically
AND ... -- other query parts
Unlike hand‑coded repositories or query extensions, filters live in the model configuration, so they:
- Apply universally—any
DbSet<Post>()
query respects the rule. - Stay DRY—one declaration, project‑wide consistency.
- Can be parameterised—so you can, e.g., inject the current tenant or user ID.

Common Use Cases (And Why You’ll Love Them)
Scenario | Why Global Filters Shine |
---|---|
Soft delete | Hide logically deleted rows while preserving history. |
Multi‑tenancy | Enforce data isolation per tenant—no more accidental cross‑reads. |
Draft / published | Show only published content in production, drafts in admin UI. |
Temporal slices | Display rows valid for a specific date range (think price lists). |
Security trimming | Exclude confidential rows based on user claims/roles. |
Once I ran into the multi‑tenant case on a fintech project. A missed tenantId
check slipped past code review and QA—thankfully discovered in staging, not production. Moving to global filters eliminated that entire class of bugs overnight.
Implementation Guidelines
Before you blanket your model with filters, keep these rules in mind:
- Side‑effect free – Filters should be pure predicates; no DB writes, logging, or lazy service resolution that might throw.
- Index first – Every column referenced by a filter predicate must be indexed (e.g.,
IsDeleted
,TenantId
). Otherwise you’re trading bugs for table scans. - Parametrize safely – Inject immutable services where possible (e.g., an
ITenantProvider
that returns a value, not a DbContext). - Disable tactically – Provide an escape hatch (
IgnoreQueryFilters
, a scoped context, or a flag property) for background jobs and admin screens. - One filter per concern – Resist the urge to create giant Franken‑predicates. Layer simple filters—EF composes them with logical AND.

Example: Multi‑Company (Tenant) Filter
public interface ICompanyAccessor { Guid CompanyId { get; } }
public class AccountingDbContext : DbContext
{
private readonly ICompanyAccessor _company;
public AccountingDbContext(DbContextOptions opts, ICompanyAccessor company)
: base(opts) => _company = company;
protected override void OnModelCreating(ModelBuilder b)
{
// Applies to every entity implementing ICompanyOwned
foreach (var entity in b.Model.GetEntityTypes()
.Where(t => typeof(ICompanyOwned).IsAssignableFrom(t.ClrType)))
{
entity.SetQueryFilter(
BuildCompanyFilterExpression(entity, _company.CompanyId));
}
}
private static LambdaExpression BuildCompanyFilterExpression(IMutableEntityType entity,
Guid companyId)
{
var param = Expression.Parameter(entity.ClrType, "e");
var prop = Expression.Property(param, nameof(ICompanyOwned.CompanyId));
var body = Expression.Equal(prop, Expression.Constant(companyId));
return Expression.Lambda(body, param);
}
}
Interface marker: ICompanyOwned { Guid CompanyId { get; } }
lets you reuse the rule across dozens of entities without repeating yourself.
Getting Started—Hello, Soft Delete
public class BlogDbContext : DbContext
{
public DbSet<Post> Posts => Set<Post>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Post>()
.HasQueryFilter(p => !p.IsDeleted);
}
}
HasQueryFilter
attaches the predicate.- EF substitutes the predicate everywhere—
ToListAsync
,FromSql
, even includes.
Tip: Need to fetch deleted rows for an admin screen? Use the context created without the filter.
Advanced Filter Wizardry
Global filters are just expression trees, but you can bend them in surprising ways. The next sub‑sections show four tricks I use daily—each with a short why it works rundown so you can adapt the idea to your own domain.
Injecting Services Into Filters
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Post>()
.HasQueryFilter(p => p.CompanyId == _companyAccessor.CompanyId);
}
How it works_companyAccessor
is captured as a closure variable inside the LINQ expression tree that EF compiles into SQL. Each time a query executes, EF fetches the current CompanyId
from the accessor and inlines it as a SQL parameter, e.g. @__currentCompanyId
. No reflection, no magic strings—just plain C#.
Runtime Toggles
public bool SoftDeleteEnabled { get; set; } = true;
b.Entity<Post>()
.HasQueryFilter(p => !p.IsDeleted || !SoftDeleteEnabled);
How it works
Because SoftDeleteEnabled
is a mutable field on the DbContext instance, EF evaluates its value per query execution. Flip it to false
inside a background job and the very next LINQ call will bypass the soft‑delete predicate, yet the rest of the app keeps the filter on.
Scoped Contexts for Background Jobs
services.AddDbContext<BlogDbContext>(); // with filters
services.AddDbContext<BlogAdminDbContext>(); // derived type without filters
How it works
Both registrations point to the same connection string, but BlogAdminDbContext
overrides OnModelCreating
and omits the filter definitions. You get two logically different models against one physical database—a clean way to give Hangfire or Quartz workers unrestricted access while the web front‑end stays locked down.
SQL Functions & EF Core 8 Goodies
b.Entity<Transaction>()
.HasQueryFilter(t =>
EF.Functions.DateDiffDay(t.Timestamp, DateTime.UtcNow) < 365);
How it worksEF.Functions
acts as a bridge to database‑native functions (SQL Server’s DATEDIFF
, PostgreSQL’s AGE
, etc.). Because the call appears in the expression tree, EF translates it 1‑to‑1, keeping the heavy date math on the server side and your LINQ readable.
Heads‑up: Server functions differ by provider—write an extension method per provider or wrap the logic behind a conditional compilation directive to stay truly portable.
Interceptors: Beyond Filters
Global filters trim reads, but what about cross‑cutting writes like auditing, validation, or soft‑delete enforcement? Enter EF Core Interceptors—hooks that fire during SaveChanges, command execution, and more.
Types of Interceptors
Interceptor Interface | Fires On |
---|---|
ISaveChangesInterceptor | SaveChanges[Async] pipeline |
ICommandInterceptor | Low‑level ADO.NET commands |
IDbConnectionInterceptor | Connection open/close |
IQueryInterceptor (EF Core 8) | LINQ translation & execution |

Implementing an Interceptor
public class AuditSaveChangesInterceptor : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(DbContextEventData d,
InterceptionResult<int> result)
{
var ctx = d.Context;
foreach (var e in ctx!.ChangeTracker.Entries<IAuditable>())
{
switch (e.State)
{
case EntityState.Added:
e.Property("CreatedAt").CurrentValue = DateTime.UtcNow;
e.Property("ModifiedAt").CurrentValue = DateTime.UtcNow;
break;
case EntityState.Modified:
e.Property("ModifiedAt").CurrentValue = DateTime.UtcNow;
break;
}
}
return base.SavingChanges(d, result);
}
}
Register it once:
services.AddSingleton<AuditSaveChangesInterceptor>();
services.AddDbContext<AppDbContext>((sp, opts) =>
{
opts.UseSqlServer(cfg.ConnectionString)
.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>());
});
Example: Auditing Interceptor in Action
var post = new Post { Title = "Hello Interceptors" };
ctx.Add(post);
await ctx.SaveChangesAsync();
// SQL inserts include CreatedAt / ModifiedAt populated automatically
Best Practices & Limitations
- Keep it fast – Heavy logic slows every save.
- Be idempotent – Interceptors may run multiple times in retry scenarios.
- Prefer shadow properties – Avoid interface bloat; see next section.
Shadow Properties for Transparent Audit
A shadow property lives in the EF model but not in your CLR class—perfect for timestamps, soft‑delete flags, or row versions that shouldn’t pollute domain objects.
Defining Shadow Properties
modelBuilder.Entity<Post>()
.Property<DateTime>("CreatedAt");
modelBuilder.Entity<Post>()
.Property<DateTime>("ModifiedAt");
Example: Shadow Properties + Audit Interceptor
Let’s wire everything together—entity without audit fields, model configuration with shadow properties, and an interceptor that stamps them.
public class Post // No CreatedAt/ModifiedAt here!
{
public Guid Id { get; set; }
public string Title { get; set; } = string.Empty;
}
public class BloggingContext : DbContext
{
protected override void OnModelCreating(ModelBuilder b)
{
// Declare shadow props
b.Entity<Post>()
.Property<DateTime>("CreatedAt");
b.Entity<Post>()
.Property<DateTime>("ModifiedAt");
// Optional: add index for reporting queries
b.Entity<Post>()
.HasIndex("CreatedAt");
}
}
public class AuditSaveChangesInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData d,
InterceptionResult<int> result,
CancellationToken ct = default)
{
var now = DateTime.UtcNow;
var ctx = d.Context!;
foreach (var e in ctx.ChangeTracker.Entries<Post>())
{
if (e.State == EntityState.Added)
{
e.Property("CreatedAt").CurrentValue = now;
e.Property("ModifiedAt").CurrentValue = now;
}
else if (e.State == EntityState.Modified)
{
e.Property("ModifiedAt").CurrentValue = now;
}
}
return base.SavingChangesAsync(d, result, ct);
}
}
What happens under the hood?
- Model phase – EF creates two extra columns in migrations even though
Post
doesn’t expose them. - Save phase – The interceptor sets the column values using
Entry.Property("CreatedAt")
—no reflection needed, and compile‑time safety is preserved. - Query phase – When you need those columns, access them via
EF.Property<T>(entity, "CreatedAt")
so the provider can translate to SQL.
var stale = await ctx.Posts
.Where(p => EF.Property<DateTime>(p, "ModifiedAt") < DateTime.UtcNow.AddMonths(-6))
.ToListAsync();
Performance tip: Because audit columns are written on every insert/update, keep them in the same table to avoid joins, but add indexes if you run date‑range reports.
Querying Shadow Properties
var recentPosts = ctx.Posts
.Where(p => EF.Property<DateTime>(p, "CreatedAt") > DateTime.UtcNow.AddDays(-7))
.ToList();
Shadow properties participate fully in LINQ and SQL generation; they’re just invisible in the POCO API.
Performance Considerations
- Index support — Ensure filtered columns (
IsDeleted
,CompanyId
) are indexed; otherwise the extra predicates may slow queries. - Parameter sniffing — If the tenant ID varies, SQL Server may reuse sub‑optimal plans. Consider
OPTION (RECOMPILE)
viaTagWith
for hot paths. - Join explosion — Complex filters on many entities can bloat generated SQL. Use the EF Core split query mode for massive includes.
- Interceptor overhead — Run a profiler; a poorly‑written interceptor can dwarf query time.
Testing Strategy
- Unit tests: Use the in‑memory provider; assert that queries exclude deleted rows and that interceptors set audit fields.
- Integration tests: Capture
ctx.Database.GenerateSql()
(orToQueryString()
) and verify SQL contains your predicates. - Mutation tests: Flip
IsDeleted
flags and ensure they disappear/appear as expected.
I once caught an edge case where a test seeded IsDeleted = null
—the filter !p.IsDeleted
failed (null != false). We switched to == false
to coerce nulls and saved future headaches.
Gotchas & Best Practices
- Navigation properties inherit filters only on the root entity. Include carefully.
- Shadow properties work fine for audit but remember to migrate them; EF will create the columns.
- Migrations: Filters do not create DB‑side filtered indexes—manage those manually.
- Security: Don’t rely solely on filters—combine with API and DB‑level authorization.
- Avoid magic strings: Wrap shadow property names in constants to dodge typos.
Patterns & Architectures
Global filters are invisible helpers that slot nicely into several mainstream .NET architectural styles.
Repository + Specification
// Domain‑agnostic specification
public class PublishedSpec : Specification<Post>
{
public PublishedSpec()
{
Query.Where(p => p.PublishedAt <= DateTime.UtcNow)
.Include(p => p.Tags)
.OrderByDescending(p => p.PublishedAt);
}
}
// Repository call site
var items = await _repo.ListAsync(new PublishedSpec());
Why it fits
The repository and specification add extra predicates; the global filters (soft delete, tenant) remain hidden. You avoid the “forgot the Where‑clause” bug without littering every spec with identical checks.
CQRS / MediatR
public record GetPostById(Guid Id) : IRequest<PostDto?>;
public class Handler : IRequestHandler<GetPostById, PostDto?>
{
public async Task<PostDto?> Handle(GetPostById request, CancellationToken ct)
{
return await _ctx.Posts
.ProjectTo<PostDto>(_mapperCfg)
.FirstOrDefaultAsync(p => p.Id == request.Id, ct);
}
}
Why it fits
Read‑side handlers stay razor thin—no tenant checks or soft‑delete clauses—and the write side can explicitly disable filters when business rules demand (e.g. restoring a deleted post). This keeps your command and query handlers symmetrical and testable.
Clean Architecture / Onion
Place your filters and interceptors in the Infrastructure layer, inject any runtime parameters (tenant, user) from the API layer, and expose a pure Domain layer free of cross‑cutting noise. Because filters are additive, they don’t leak into domain entities or value objects.
GraphQL with HotChocolate
HotChocolate enumerates IQueryable chains—so global filters are applied automatically before the GraphQL engine runs projection middleware. The result: your schema exposes only tenant‑safe data without manual guards.
public class Query
{
[UseProjection]
public IQueryable<Post> GetPosts([Service] BlogDbContext ctx) => ctx.Posts;
}
Microservice Outbox Pattern
Pair a SaveChangesInterceptor
that writes outbox messages to a table with a background worker reading that table. Global filters ensure outbox rows are tenant‑scoped; the worker context disables them to dispatch events across companies when needed.
ctx.ChangeTracker.Entries<IOutboxMessage>()
.Where(e => e.State == EntityState.Added)
.ToList()
.ForEach(e => e.Entity.TenantId = _tenantId);
This prevents a rogue service from publishing data that belongs to a different tenant.

FAQ: EF Core Global Filter Essentials
Yes—attach .IgnoreQueryFilters()
at the end:ctx.Posts.IgnoreQueryFilters().ToListAsync();
FromSql
?Yes, EF still injects predicates after your FROM
clause. Use AsNoTracking()
plus IgnoreQueryFilters()
if you need untouched raw results.
Filters act within a context. If tenants live in separate schemas or DBs, prefer a DbContext
per tenant instead of a filter.
No—only if your predicate contains unsupported CLR methods. Stick to column comparisons or EF.Functions
.
Negligibly—they’re regular columns. Just index them if you query often.
Query interceptors (EF Core 8) can tag or rewrite command text, but heavy logic belongs in business services.
Conclusion: Stay DRY, Stay Safe—Embrace Global Filters
From tenant isolation to audit trails, EF Core’s global filters, interceptors, and shadow properties let you bake cross‑cutting data rules once and forget about them. Less boilerplate, fewer production slips, happier devs. Ready to refactor your next project? Try migrating a single entity today and watch the clutter vanish.
What tricky scenario have you solved (or broken!) with EF Core global filters or interceptors? Share in the comments—I read every one.
- Introduction to EF Core: Power Up Your .NET Data Access
- Install EF Core Fast: Step-by-Step Beginner’s Guide
- EF Core Global Filters: Soft Delete & Multi‑Tenant Guide
- Entity Framework Data Annotations & Fluent API Configuration
- EF Core Transactions Guide & Code Examples (2025)