Global query filters
When you're building a SaaS platform or any multi-tenant application, you quickly realize that writing WHERE TenantId == currentTenant in every single query becomes a maintenance nightmare. Global query filters let you define filtering logic once in your model configuration, and Entity Framework automatically applies it to every query that touches that entity. This article walks you through implementing soft deletes and tenant isolation at the data layer—without polluting your queries.
What are global query filters and why they matter
A global query filter is a LINQ predicate that EF Core applies to every query on a specific entity type. You define it once in OnModelCreating, and from that point forward, every DbSet<T>.Where(), Find(), or navigation property load includes your filter automatically. This is powerful because:
- You avoid scattered, repetitive WHERE clauses across dozens of repositories or services.
- New developers can't accidentally forget to filter by TenantId or check IsDeleted—it's baked into the model.
- Soft deletes and multi-tenancy become consistent across your entire application.
- You maintain a single source of truth for what data a query should see.
In my experience building a ride-hailing platform at my previous role, we had a bug where one endpoint forgot to filter by TenantId. A customer from one workspace saw bookings from another workspace. A global query filter would have prevented that instantly.
Define the filter once in OnModelCreating
The entry point for global query filters is your DbContext.OnModelCreating() method. You call HasQueryFilter() on the entity builder and pass a lambda expression that returns a boolean.
public class ApplicationDbContext : DbContext
{
private readonly ICurrentTenantService _tenantService;
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options, ICurrentTenantService tenantService)
: base(options)
{
_tenantService = tenantService;
}
public DbSet<Order> Orders { get; set; }
public DbSet<Product> Products { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Global filter for soft deletes and tenant isolation
modelBuilder.Entity<Order>()
.HasQueryFilter(o => !o.IsDeleted && o.TenantId == _tenantService.CurrentTenantId);
modelBuilder.Entity<Product>()
.HasQueryFilter(p => !p.IsDeleted && p.TenantId == _tenantService.CurrentTenantId);
}
}
Here's what happens: when you query var orders = await context.Orders.ToListAsync();, EF Core translates this to something like SELECT * FROM Orders WHERE IsDeleted = 0 AND TenantId = 'tenant-123'. The filter is invisible to your controller or service code—it just works.
The entities themselves are simple:
public class Order
{
public int Id { get; set; }
public string TenantId { get; set; }
public string OrderNumber { get; set; }
public decimal Total { get; set; }
public bool IsDeleted { get; set; }
public DateTime CreatedAt { get; set; }
}
public class Product
{
public int Id { get; set; }
public string TenantId { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public bool IsDeleted { get; set; }
}
Notice that TenantId and IsDeleted are properties on the entity. The global query filter combines both concerns—soft delete and multi-tenancy—into a single WHERE clause that travels with every query.
Every query gets the WHERE clause automatically
Once you set up the filter, it's applied transparently across your entire application. Let me show you how this manifests in real code:
// In your OrderService
public class OrderService
{
private readonly ApplicationDbContext _context;
public OrderService(ApplicationDbContext context)
{
_context = context;
}
public async Task<List<Order>> GetOrdersByStatus(string status)
{
// Behind the scenes, this query includes:
// WHERE IsDeleted = 0 AND TenantId = 'current-tenant'
return await _context.Orders
.Where(o => o.OrderNumber.StartsWith(status))
.ToListAsync();
}
public async Task<Order> GetOrderById(int id)
{
// Find also respects the global filter
var order = await _context.Orders.FindAsync(id);
return order; // null if soft-deleted or from another tenant
}
public async Task<int> GetTotalOrderCount()
{
// Count includes the filter
return await _context.Orders.CountAsync();
}
}
This is where the elegance shines. Your code doesn't change. You write queries as if you're only working with active, current-tenant data. The filter is silent but omnipresent. In a trading platform I built, we had hundreds of queries across different services, and not a single one leaked data from other tenants or returned soft-deleted records. That's the power of a global query filter.
Navigation properties also respect the filter:
public class Customer
{
public int Id { get; set; }
public string TenantId { get; set; }
public string Name { get; set; }
public bool IsDeleted { get; set; }
// Navigation property
public ICollection<Order> Orders { get; set; }
}
// When you load a customer and navigate to Orders:
var customer = await _context.Customers.FirstOrDefaultAsync(c => c.Id == 1);
var orders = await customer.Orders.ToListAsync(); // Still filtered by TenantId and IsDeleted
IgnoreQueryFilters for admin tools and recovery workflows
Sometimes you need to bypass the global filter. Imagine an admin tool that restores soft-deleted orders, or a data recovery script that needs to see everything. That's where IgnoreQueryFilters() comes in.
public class AdminOrderService
{
private readonly ApplicationDbContext _context;
public AdminOrderService(ApplicationDbContext context)
{
_context = context;
}
public async Task<List<Order>> GetAllOrdersIncludingDeleted()
{
// Bypass the global filter entirely
return await _context.Orders
.IgnoreQueryFilters()
.ToListAsync();
}
public async Task RestoreOrder(int orderId)
{
// Fetch a soft-deleted order without the filter
var order = await _context.Orders
.IgnoreQueryFilters()
.FirstOrDefaultAsync(o => o.Id == orderId);
if (order != null)
{
order.IsDeleted = false;
await _context.SaveChangesAsync();
}
}
public async Task<List<Order>> GetOrdersAcrossAllTenants()
{
// See data from all tenants (use with caution!)
return await _context.Orders
.IgnoreQueryFilters()
.Where(o => !o.IsDeleted) // You might still want to exclude deleted records
.ToListAsync();
}
}
A critical note: IgnoreQueryFilters() should be restricted. In a real application, I'd wrap it in authorization checks and audit logging. An admin who calls GetAllOrdersAcrossAllTenants() shouldn't do so without explicit permission and a log entry.
Great for TenantId isolation in SaaS
Multi-tenancy is one of the most common use cases for global query filters. When you have 100 customers running on the same database, a single mistake—a forgotten WHERE clause—can expose data. Global filters make this nearly impossible.
Here's a realistic SaaS scenario:
public class SaasPlatformDbContext : DbContext
{
private readonly ICurrentTenantService _tenantService;
public SaasPlatformDbContext(DbContextOptions options, ICurrentTenantService tenantService)
: base(options)
{
_tenantService = tenantService;
}
public DbSet<Workspace> Workspaces { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<Invoice> Invoices { get; set; }
public DbSet<Report> Reports { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
var tenantId = _tenantService.CurrentTenantId;
// Every entity in this SaaS app respects TenantId
modelBuilder.Entity<User>()
.HasQueryFilter(u => u.TenantId == tenantId);
modelBuilder.Entity<Invoice>()
.HasQueryFilter(i => i.TenantId == tenantId);
modelBuilder.Entity<Report>()
.HasQueryFilter(r => r.TenantId == tenantId);
// Workspace data is per-tenant too
modelBuilder.Entity<Workspace>()
.HasQueryFilter(w => w.TenantId == tenantId);
}
}
Now, a customer service representative can write simple, clean queries:
public class CustomerServiceEndpoint
{
private readonly SaasPlatformDbContext _context;
public CustomerServiceEndpoint(SaasPlatformDbContext context)
{
_context = context;
}
[HttpGet("invoices/{invoiceId}")]
public async Task<IActionResult> GetInvoice(int invoiceId)
{
// Only returns invoices for the current tenant
var invoice = await _context.Invoices.FindAsync(invoiceId);
return invoice == null ? NotFound() : Ok(invoice);
}
[HttpGet("users")]
public async Task<IActionResult> ListUsers()
{
// Automatically filtered by current tenant
var users = await _context.Users.ToListAsync();
return Ok(users);
}
}
Every endpoint is secure by default. Even if someone accidentally removes a WHERE clause during a refactor, the global filter catches it. I've seen this pattern in production SaaS platforms handling thousands of tenants, and it's rock-solid when implemented correctly.
Common mistakes
- Forgetting to inject ICurrentTenantService: If your DbContext doesn't have access to the current tenant ID at construction time, the filter can't work. Make sure your tenant service is properly scoped or injected.
- Using static tenant IDs: Don't hardcode a tenant ID in the filter. Always pull it from a service that respects the current request context. Otherwise, all tenants will see the same data.
- Applying filters to audit tables: If you have a table that logs all deletes across all tenants (an audit log), don't apply a TenantId filter to it. Reserve filters for business entities, not system-wide logs.
- Not testing IgnoreQueryFilters: Your admin tools won't work if you forget to call
IgnoreQueryFilters(). Always test the code path that bypasses filters. - Filters on read-only projections: If you're projecting to a DTO early in the query, filters still apply, but they apply to the underlying table, not the DTO. Be aware of how filters interact with
Select().
Key takeaways
- Global query filters eliminate the need to repeat WHERE clauses across your codebase by defining them once in
OnModelCreating. - Filters apply automatically to every query, including navigation properties and
Find(), making them ideal for soft deletes and multi-tenancy. - Use
IgnoreQueryFilters()carefully for admin tools and data recovery, with proper authorization and logging in place. - In SaaS applications, global query filters provide a reliable safety net against accidental data leaks between tenants.
- Always inject your tenant service into the DbContext and pull the current tenant ID from the request context, never from a static source.






Comments
Loading comments…