Why your Entity Framework Core app needs query filters

Published: Monday 30 March 2026

// ❌ This works… but don't add this
// in Program.cs
app.MapGet("/Product", () =>
new ProductDto(1, "Watch"));

We show you the correct way to organise Minimal API endpoints using separate endpoint classes → Learn more

Most EF Core developers handle soft deletes by adding a Where clause to every query.

And it works - until someone forgets to add it, and suddenly deleted records start showing up in production.

EF Core has a built-in feature that solves this properly, and once you start using it, you will never go back.

The problem

Let us start with a typical database setup. We have a Products table and some of the records have been soft deleted.

Id

SiteId

Name

Deleted

1

1

Television

NULL

2

1

Radio

NULL

3

1

Games console

2026-03-30 06:00:00

4

2

PC

NULL

5

2

Mobile phone

NULL

6

2

Tablet

2026-03-30 06:00:00

If you query all products using EF Core:

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

Every record comes back, including the deleted ones.

The common bad pattern

The usual solution is to add filters everywhere:

var products = await _context.Products.Where(x => !x.Deleted.HasValue).ToListAsync();

And that works... until someone forgets to add the filter and suddenly deleted records start appearing in production.

Introducing query filters

Entity Framework Core has a feature called query filters that automatically applies a filter to every query for a given entity. You write the rule once, and EF Core enforces it everywhere.

Implement the filter

To enable this, we go into our entity configuration and add a query filter using HasQueryFilter.

// ProductConfiguration.cs
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
	public void Configure(EntityTypeBuilder<Product> builder)
	{
		builder.HasKey(x => x.Id);

		// Add this line
		builder.HasQueryFilter(x => !x.Deleted.HasValue);

		builder.Property(x => x.Name)
			.HasMaxLength(100);

		builder.ToTable("Products");
	}
}

This tells EF Core that when you query Products, automatically exclude anything that has been soft deleted.

Now when you run the same query again:

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

The products that have been soft deleted are excluded.

Id

SiteId

Name

Deleted

1

1

Television

NULL

2

1

Radio

NULL

4

2

PC

NULL

5

2

Mobile phone

NULL

Soft delete automation

We can take this one step further. Right now, if someone calls Remove, EF Core will perform a hard delete, which defeats the whole purpose of soft deletes.

public async Task<bool> DeleteAsync(int id)
{
	var product = await _context.Products.SingleOrDefaultAsync
		(x => x.Id == id);

	if (product == null)
	{
		return false;
	}

	_context.Products.Remove(product); // This will hard delete a product
	await _context.SaveChangesAsync();

	return true;
}

Instead, we can intercept deletes inside SaveChangesAsync from the DbContext and convert them into updates that set the Deleted timestamp.

// EFQueryFilterDbContext.cs
public class EFQueryFilterDbContext : DbContext
{
	...

	public override async Task<int> SaveChangesAsync(
		CancellationToken cancellationToken = default)
	{
		foreach (var item in ChangeTracker.Entries<Product>()
			.Where(x => x.State == EntityState.Deleted))
		{
			item.State = EntityState.Modified;
			item.CurrentValues["Deleted"] = DateTime.UtcNow;
		}

		return await base.SaveChangesAsync(cancellationToken);
	}
}

Now every Remove call becomes a soft delete automatically, and your application code does not need to change.

A couple of things to note:

  • You must remember to add Where(e => e.State == EntityState.Deleted). Otherwise, all updated products will be soft deleted.

  • You must match the column name to the property name in your entity that you wish to update for it to work.

Using Entity Framework Core in a real-world application

If you want to see how Entity Framework Core fits into a real-world application, we cover it in our Minimal APIs for complete beginners course, where we build a full system from start to finish.

Adding multitenancy

Query filters are not just for soft deletes. They are also one of the simplest ways to implement multitenancy in EF Core.

First, we inject the site options into the DbContext, then we set SiteId as a property. The SiteOptions is bound to configuration in appsettings.json.

{
	...
	"Site": {
		"SiteId": 1
	}
}
// SiteOptions.cs
public class SiteOptions
{
	public required int SiteId { get; init; }
}
// EFQueryFilterDbContext.cs
public class EFQueryFilterDbContext : DbContext
{
	... 
	public int SiteId { get; }

	public EFQueryFilterDbContext(
		DbContextOptions<EFQueryFilterDbContext> options,
		IOptionsSnapshot<SiteOptions> siteOptions) 
		: base(options)
	{
		SiteId = siteOptions.Value.SiteId;
	}

	...
}

We then include the EFQueryFilterDbContext as a field in the entity configuration and add the SiteId to the query filter.

// ProductConfiguration.cs
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
	private readonly EFQueryFilterDbContext _context = null!;

	public void Configure(EntityTypeBuilder<Product> builder)
	{
		…

		builder.HasQueryFilter(x => 
			x.SiteId == _context.SiteId && !x.Deleted.HasValue);

		…
	}
}

This means that every query is now automatically scoped to the current tenant.

Introducing named query filters

EF Core introduced named query filters in .NET 10. Previously, if you called HasQueryFilter multiple times, the last one would overwrite the others.

// ProductConfiguration.cs
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
	...

	public void Configure(EntityTypeBuilder<Product> builder)
	{
		...

		builder.HasQueryFilter(x => x.SiteId == _context.SiteId); // Gets overwritten
		builder.HasQueryFilter(x => !x.Deleted.HasValue);

		...
	}
}

To add named query filters, you add the name as the first parameter. We store the names in constant strings so they can be reused in queries.

// ProductConfiguration.cs
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
	public const string MultiTenancyQueryFilter = "MultiTenancy";

	public const string ActiveQueryFilter = "Active";

	...

	public void Configure(EntityTypeBuilder<Product> builder)
	{
		...

		builder.HasQueryFilter(MultiTenancyQueryFilter,
			x => x.SiteId == _context.SiteId);
		builder.HasQueryFilter(ActiveQueryFilter,
			x => !x.Deleted.HasValue);

		...
	}
}

Now both filters coexist, and we can selectively disable them when needed.

Ignoring query filters

If you want to exclude query filters on a particular query, you can call IgnoreQueryFilters.

var products = await _context.Products.IgnoreQueryFilters.ToListAsync();

This excludes all query filters. If there are specific filters you want to exclude, you can include them in the string[] parameter.

await _context.Products
	.IgnoreQueryFilters[ProductConfiguration.MultiTenancyQueryFilter].ToListAsync();

Watch the video

Watch the video where we walk you through step-by-step how to add global query filters into your application.