Skip to main content

How to Add Optional Filters to an EF Core Query

· 5 min read
Jagdish Kumawat
Founder @ Dewiride

A list endpoint usually has optional filters: a category, a search term, a checkbox. Since an EF Core query doesn't run until the end, you can add each Where only when its filter is present, and get SQL with exactly those conditions. This guide builds that, and shows the SQL for different requests.

Prerequisites​

  • A .NET C# project with EF Core. The examples use EF Core 10, Oracle's MySql.EntityFrameworkCore, MySQL 9.7, and a products table. SQL logging is on, as in How to See the SQL That EF Core Sends.

Step 1: Start from the Table, as a Query​

src/Dewiride.Inventory.Api/Features/Products/ProductEndpoints.cs (excerpt)
IQueryable<Product> products = db.Products;

The type matters. IQueryable<Product> is a description of a query, so you can keep adding to it. Declaring it explicitly lets you reassign it with Where, which returns IQueryable, not DbSet.

Step 2: Add Each Filter Only When It's Given​

src/Dewiride.Inventory.Api/Features/Products/ProductEndpoints.cs (excerpt)
if (!query.IncludeArchived)
{
products = products.Where(p => !p.IsArchived);
}

if (query.CategoryId is int categoryId)
{
products = products.Where(p => p.CategoryId == categoryId);
}

if (!string.IsNullOrWhiteSpace(query.Search))
{
var search = query.Search.Trim();
products = products.Where(p => p.Name.Contains(search) || p.Sku.Contains(search));
}

Nothing has gone to MySQL yet. EF Core's How Queries Work page says the query "is only sent to the database when the results are consumed." The record query comes from the query string, as in How to Bind Query String Parameters.

Step 3: Run It, and Read the SQL​

Run it at the end, with ToListAsync or CountAsync:

src/Dewiride.Inventory.Api/Features/Products/ProductEndpoints.cs (excerpt)
var totalCount = await products.CountAsync(cancellationToken);

With no filters in the request, GET /api/v1/products, only the default IsArchived filter applies:

API log (the count)
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT COUNT(*)
FROM `products` AS `p`
WHERE NOT (`p`.`IsArchived`)

With a category, ?categoryId=2, the conditions join with AND:

API log (the count)
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@categoryId='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
SELECT COUNT(*)
FROM `products` AS `p`
WHERE NOT (`p`.`IsArchived`) AND (`p`.`CategoryId` = @categoryId)

With a search, ?search=usb-c&sort=-price:

API log (the count)
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (3ms) [Parameters=[@search='?' (Size = 200), @search0='?' (Size = 32)], CommandType='Text', CommandTimeout='30']
SELECT COUNT(*)
FROM `products` AS `p`
WHERE NOT (`p`.`IsArchived`) AND (((@search LIKE '') OR (LOCATE(@search, `p`.`Name`) > 0)) OR ((@search0 LIKE '') OR (LOCATE(@search0, `p`.`Sku`) > 0)))

A terminal with three SELECT COUNT statements for different requests: one with only NOT IsArchived, one with a CategoryId filter added, and one with the search conditions using LOCATE on Name and Sku.

Each request got its own WHERE, with no leftovers. Several Where calls in a row become one WHERE … AND ….

Why Not One Big Where?​

You might write all the filters in one condition, with checks for "not given":

A temporary endpoint
products = products.Where(p =>
(categoryId == null || p.CategoryId == categoryId) &&
(search == null || p.Name.Contains(search)));

I expected that to send MySQL conditions that only exist to switch themselves off. It doesn't. With no filters, EF Core leaves them out:

API log
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT COUNT(*)
FROM `products` AS `p`

With ?categoryId=2, only the category's condition is left:

API log
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@categoryId='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
SELECT COUNT(*)
FROM `products` AS `p`
WHERE `p`.`CategoryId` = @categoryId

So the SQL is just as clean here, and this comes down to the code. Pieces are easier to read as filters are added, and they have room for the steps a condition can't hold, such as the Trim() and IsNullOrWhiteSpace above.

Common Mistakes​

MistakeWhat happensDo this instead
var products = db.Products;Where returns IQueryable, so the assignment doesn't compileDeclare IQueryable<Product>
Forgetting to assign the result of WhereThe filter is silently lost: Where doesn't change the query it's called onproducts = products.Where(…)
Calling ToListAsync firstEvery row loads, and filters run in C#Run the query last
Treating "" as a searchContains("") matches everythingUse IsNullOrWhiteSpace

FAQ​

Can I use OR between optional filters?​

Not by chaining Where, which always means AND. Put the || inside one Where, as the search does, or use a library that builds predicates.

Is the SQL cached?​

EF Core caches each query shape it has translated, so several shapes are fine. It's only a concern if you have an enormous number of filter combinations.

Conclusion​

Start with IQueryable<Product> products = db.Products;, add products = products.Where(…) inside an if for each filter, and run the query at the end. MySQL gets a WHERE with exactly the conditions that request needed.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.