Updated on
Filtering a Web API means letting the client narrow a collection endpoint with query-string parameters the API has declared: ?minYearOfBirth=1975&maxYearOfBirth=1997 returns only the owners born in that range.
This is not the same thing as an ASP.NET Core filter. IActionFilter, IResourceFilter and the rest are pipeline hooks that run around an action, and they have nothing to do with narrowing a result set. If those are what brought you here, filters in ASP.NET Core MVC is the article you want.
NOTE: Some degree of previous knowledge is needed to follow this article. It relies heavily on the ASP.NET Core Web API series on Code Maze, so if you are not sure how to set up the database or how the underlying architecture works, we strongly suggest you go through the series.
VIDEO: Filtering Alongside Pagination in Web API Project.
Every filter is a Where clause added to a query that has not run yet, which is why filtering composes with paging and sorting without any of the three knowing about the others.
What Is Filtering in a Web API?
Filtering narrows a collection endpoint to the records matching criteria the client sends as query-string parameters. The API decides which criteria exist; the client only chooses values for them.
That fixed vocabulary is the point. ?minYearOfBirth=1975&maxYearOfBirth=1997 works because the API declared both parameters, and anything the API did not declare is ignored rather than obeyed.
Filtering is not the same thing as an ASP.NET Core filter. IActionFilter, IResourceFilter and their siblings are pipeline hooks that run around an action; they never touch a result set, and they share nothing with this but a word.
Every filter composes onto one query. A filter is a Where clause, paging is a Skip and a Take, and sorting is an OrderBy, so all three can apply to a single request without any one of them being aware of the other two, and the database still sees only one statement.
On the front end, filtering is usually implemented as checkboxes, radio buttons or dropdowns. This kind of implementation limits you to only those options that are available to create a valid filter.
Take for example a car-selling website. When filtering the cars you want, you would ideally want to select:
- Car manufacturer as a category from a list or a dropdown
- Car model from a list or a dropdown
- Is it new or used with radio buttons?
- The city where the seller is as a dropdown
- The price of the car is an input field (numeric)
- ….
You get the point. So the request would look something like this:
https://bestcarswebsite.com/sale?manufacturer=ford&model=expedition&state=used&city=washington&price_from=30000&price_to=50000
Or even like this:
https://bestcarswebsite.com/sale/filter?data[manufacturer]=ford&[model]=expedition&[state]=used&[city]=washington&[price_from]=30000&[price_to]=50000
Or anything else that makes sense to you the most. The API needs to parse the filter, so we don’t need to get too crazy with it :).
If we would rather not invent a filter vocabulary of our own, exposing the whole query surface with OData gives us a standard one that clients already know how to write.
Now that we know what filtering is, let’s see how it’s different from searching.
How Is Filtering Different From Searching?
Filtering matches named fields; searching matches free text against whatever the API has decided is searchable.
A filter is structured. The client sends manufacturer=ford, the API knows manufacturer is a column, and the comparison is exact. The set of valid filters is finite and documented, which is why filters render as dropdowns and checkboxes on a front end.
A search is one box. The client sends ford focus and the API decides what that means: which fields to look in, whether the two words are matched together or separately, and how much a partial match counts for.
The difference shows most clearly in what an unrecognised value does. An unknown filter value returns nothing, because no record met the criterion. An unknown search term returns whatever came closest, because relevance is the API’s judgement rather than the client’s instruction.
We can also improve the search by implementing search terms like Google does it for example. If the user enters Ford Expedition without quotes in the search field, we would return both what’s relevant to Ford and Expedition. But, if the user puts the quotes around it, we would search the entire term “Ford Expedition” in our database.
It makes for better user experience.
Example:
https://bestcarswebsite.com/sale/search?name=ford focus
Using search doesn’t mean we can’t use filters together with it. It makes perfect sense to use the filtering and searching together, so we need to take that into account when writing our source code.
But enough of the theory.
Let’s implement some filters.
How Do We Add Filter Parameters to a Controller?
A filter parameter is a property on the parameters class the action already binds with [FromQuery]. Adding a filter means adding a property, not adding an argument to the action signature.
Make each one nullable. A nullable property separates “the client did not ask” from “the client asked for zero”, and that distinction is what lets the repository leave a clause out entirely instead of comparing against an invented default.
Validate with attributes rather than an if. A [Range] on the property lets the [ApiController] attribute reject bad input automatically and answer with a ProblemDetails body, so the action body only ever runs on values that already make sense.
Then build the query by composition. Start from the unfiltered IQueryable, add one Where for each filter the client actually sent, and the provider folds the whole chain into a single statement.
We would need a query like this one:
https://localhost:5001/api/owners?minYearOfBirth=1975&maxYearOfBirth=1997
But, we want to be able to do this too:
https://localhost:5001/api/owners?minYearOfBirth=1975
Or like this:
https://localhost:5001/api/owners?maxYearOfBirth=1997
Ok, we have a specification. Let’s see how to implement it.
We’ve already implemented paging in our controller so we have the necessary infrastructure to extend it with the filtering functionality. We’ve used the OwnerParameters class to define the query parameters for our paging request.
Let’s extend our OwnerParameters class to support filtering too:
public class OwnerParameters : QueryStringParameters
{
[Range(1900, 2100)]
public int? MinYearOfBirth { get; set; }
[Range(1900, 2100)]
public int? MaxYearOfBirth { get; set; }
public bool ValidYearRange =>
MinYearOfBirth is null || MaxYearOfBirth is null || MaxYearOfBirth >= MinYearOfBirth;
}
We’ve added two nullable int properties, MinYearOfBirth and MaxYearOfBirth. Nullable is the part that matters: null means the client never sent that bound, which is a different thing from sending a zero, and it is what lets the repository leave the clause out altogether.
The [Range] attribute does the per-property validation for us. Because the controller carries [ApiController], a year outside 1900 to 2100 is rejected before our action body runs, and the client gets a ProblemDetails response naming the offending property.
That leaves one rule an attribute can’t express, because it spans two properties, and ValidYearRange covers it: the max year must not be lower than the min year. Note that it is inclusive, so asking for a single year with the same value on both ends is a legitimate request rather than a rejected one.
Okay, now that we have our parameters ready, we can extend the controller:
[HttpGet]
public async Task<ActionResult<IEnumerable<Owner>>> GetOwners([FromQuery] OwnerParameters ownerParameters)
{
if (!ownerParameters.ValidYearRange)
{
return Problem(
detail: "maxYearOfBirth must be greater than or equal to minYearOfBirth.",
statusCode: StatusCodes.Status400BadRequest);
}
var owners = await _repository.Owner.GetOwners(ownerParameters);
var metadata = new
{
owners.TotalCount,
owners.PageSize,
owners.CurrentPage,
owners.TotalPages,
owners.HasNext,
owners.HasPrevious
};
Response.Headers["X-Pagination"] = JsonSerializer.Serialize(metadata);
_logger.LogInfo($"Returned {owners.TotalCount} owners from database.");
return Ok(owners);
}
As you can see, there’s not much to it. We’ve added our cross-field check, and when it fails we answer with Problem(...) rather than a bare string, so this endpoint returns the same shape for its own validation failure as [ApiController] already returns for a bad year. If that format is new to you, we cover returning ProblemDetails from a Web API separately.
That should do it for the controller.
Let’s get to the implementation in our OwnerRepository class:
public Task<PagedList<Owner>> GetOwners(OwnerParameters ownerParameters)
{
var owners = FindAll();
if (ownerParameters.MinYearOfBirth is { } minYear)
owners = owners.Where(o => o.DateOfBirth >= new DateTime(minYear, 1, 1));
if (ownerParameters.MaxYearOfBirth is { } maxYear)
owners = owners.Where(o => o.DateOfBirth < new DateTime(maxYear + 1, 1, 1));
var sortedOwners = owners.OrderBy(o => o.Name);
return PagedList<Owner>.ToPagedListAsync(sortedOwners,
ownerParameters.PageNumber,
ownerParameters.PageSize);
}
Actually, at this point, the implementation is rather simple too.
We start from the unfiltered query and add one Where clause for each bound the client actually sent. Each clause compares DateOfBirth itself against a date instead of wrapping the column in .Year, which is what keeps an index on that column usable. The upper bound is half-open, so maxYearOfBirth=1997 still includes an owner born on the 31st of December 1997.
Pretty simple hah?
Let’s try it out.
How Do We Test the Filter Endpoint?
Like the specification states, we have a few use cases to test.
For the reference, these are the owners in our database:
- John Keen, born on the 5th of December 1980
- Anna Bosh, born on the 14th of November 1974
- Nick Somion, born on the 15th of December 1998
- Sam Query, born on the 22nd of April 1990
- Martin Miller, born on the 21st of May 1983
The initialization script that ships with the source code seeds exactly these five, so every result below is reproducible.
First, let’s test just the minYearOfBirth parameter.
https://localhost:5001/api/owners?minYearOfBirth=1975
In this case, we shouldn’t see Anna Bosh in our results.
The second test should be just the maxYearOfBirth.
https://localhost:5001/api/owners?maxYearOfBirth=1997
Now, Nick Somion should not appear amongst our owners.
And the final test is to include both minYearOfBirth and maxYearOfBirth.
https://localhost:5001/api/owners?minYearOfBirth=1975&maxYearOfBirth=1997
In this case, neither Anna nor Nick should be in the results.
The single-year case is worth its own test, because it is the one an exclusive comparison gets wrong:
https://localhost:5001/api/owners?minYearOfBirth=1990&maxYearOfBirth=1990
Since ValidYearRange is inclusive, this is a valid request, and it returns Sam Query, the only owner born in 1990.
We should also check if our validation works:
https://localhost:5001/api/owners?minYearOfBirth=1975&maxYearOfBirth=1974
We should get a bad request, as a problem details response saying the max year cannot be lower than the min year.
To top this off we can combine filtering with our current paging solution.
https://localhost:5001/api/owners?minYearOfBirth=1975&maxYearOfBirth=1997&pageSize=2&pageNumber=2
Can you guess what the result is (hint: it’s a single person)? If you’ve guessed, let us know in the comments.
That’s it, we’ve tested all the relevant cases.
Which Filter Expressions Can EF Core Translate?
EF Core translates a Where clause only if it can turn every part of it into SQL. Comparison operators, Contains, StartsWith and null checks all translate; a call into one of our own C# methods does not, and the query fails rather than quietly falling back.
Where the comparison sits decides whether an index can help. o.DateOfBirth >= from && o.DateOfBirth < to compares the column itself, so an index on that column can seek straight to the range.
Wrapping the column changes that. o.DateOfBirth.Year == 1975 becomes DATEPART(year, DateOfBirth) = 1975, and a function around a column stops the index being usable, so the database reads every row to work the answer out.
The rewrite is mechanical. Any “in this year” or “in this month” filter can be written as a half-open range between two dates, which returns the same records and leaves the column bare.
| To filter by… | Write it as | Roughly the SQL | Can an index help? |
|---|---|---|---|
| exact match | o.Name == value | Name = @p | Yes |
| one of several values | ids.Contains(o.Id) | Id IN (…) | Yes |
| a range | o.DateOfBirth >= from && o.DateOfBirth < to | DateOfBirth >= @p AND DateOfBirth < @p2 | Yes |
| a year, the wrong way | o.DateOfBirth.Year == 1975 | DATEPART(year, DateOfBirth) = @p | No, the column is wrapped |
| text starting with | o.Name.StartsWith(term) | Name LIKE @p (the parameter carries term%) | Yes |
| text containing | o.Name.Contains(term) | Name LIKE @p (the parameter carries %term%) | No, leading wildcard |
| a missing value | o.Address == null | Address IS NULL | Yes |
| an optional filter | if (v is not null) q = q.Where(…) | the clause is simply absent | n/a |
| anything else | a C# method the provider cannot read | nothing, the query throws | n/a |
One more thing shares the name and is not this at all. EF Core’s global query filters attach a Where clause to every query for an entity type at the model level, which is a good fit for soft deletes and multi-tenancy and a poor fit for anything the client chooses per request.
And if hand-rolling the whole query surface stops being worth the code, letting a library handle paging, sorting and filtering is a reasonable trade to make.
Filtering, sorting and searching over one composed query, with the projections and the validation that go with them, is built out end to end in the Ultimate ASP.NET Core Web API course.
Conclusion
We’ve covered another important concept when building RESTful APIs. Certainly not important as paging is, but filtering is often needed in APIs because we can restrict the results to only the ones we are interested in.
The solution is rather simple when you know what to do. It was also easy to implement since we had an infrastructure set up already in our paging article.
In this article we’ve learned:
- What filtering is
- How it’s different from searching
- How to implement filtering in ASP.NET Core
- Tested our implementation
Hopefully, you’ve learned something new and useful. In the next article, we’ll cover searching, and after that sorting the same endpoint.
Tested with .NET 10 and EF Core 10.

Very good article: Thanks to you Vladimir Pecanac
HI, I wish to have filtering with all properties, with is the best way for this?
Hey gerik, filtering depends on the properties you want to filter. The logic might be different for each property. This article shows you how to do it, but you need to be careful with filters and set the default values and cover your edge cases properly.
hi Vladimir, I want to filter many properties only one logic, for example name where name == gerik , surname == something, and so
Hey gerik, I beleive you’re interested in the next part of the series then – Searching. You can do as many searches as you want, but if your logic is too complex you should look into some robust solutions like Lucene.Net
Thank you Vladdimir
you can use Gridify for that. its more clean and easy way to do Filtering, Ordering and pagination in your APIs.
github : https://github.com/Alirezanet/Gridify
There is also OData as an option which handles paging, filtering, sorting and more fairly seamlessly.
I’m not a great fan of OData or GraphQL.
I like the idea of a URI you can reason about and that can easily be tweaked to discover new content.
But I know that both technologies are gaining more and more attraction
Fair enough. The OData URL format is pretty readable/hackable IMO (but it does get more messy when the spaces are encoded.)
e.g. https://localhost:5001/api/owner?$filter=YearOfBirth gt 1975 and YearOfBirth lt 1997&$skip=2&$top=2
Yes, OData is great option too. We are going to cover it in one of the future articles/series of articles.
Nice article !
I found myself working on something very similar to what you described in your article and came up with a library that could be useful in case you want to add this filtering/searching functionality to a set of ASP.NET Core API
https://github.com/candoumbe/DataFilters
The idea is to have a set of classes that abstract away the parsing of filters and be able to reuse it at will.
Let me know your opinion on this. I’ll be happy to discuss about it and welcome anyone who would like to contribute
Thank you for sharing the library Sai. Hope someone joins you in contributing, looks like it has potential.