Updated on
Searching in a Web API means accepting a free-text term as a query parameter and narrowing the result set to the rows that contain it. One extra property on the parameters class, one Where() call in the repository, and GET /api/owners?name=anna works.
Searching is not filtering. A filter takes a value the client already knows and matches it exactly, a year, a status, an id. A search takes a fragment the client is guessing at and matches it partially, which is why it lands as LIKE '%anna%' in SQL and why it behaves differently on indexes and on case.
This is the third part of the advanced series, and it builds directly on filtering, where the client already knows the value it wants.
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: Searching With ASP.NET Core Web API Alongside Paging and Filtering.
Let’s dive right into it.
What Is Searching in a Web API?
Searching is partial, case-forgiving matching against a term the client supplies at request time. It arrives as a query parameter, and the API returns every row containing that fragment anywhere in the searched column.
The distinction from filtering is the one worth holding on to. Filtering narrows by a value the client already knows and compares it exactly, so minYearOfBirth=1974 either matches a row or does not. Searching narrows by a fragment the client is guessing at, which becomes a LIKE pattern rather than an equality test.
That difference has consequences beyond the syntax. An exact filter can seek straight down an index; a search for a fragment anywhere in a column cannot, because the database has no way to jump to the middle of a stored value.
Both mechanisms operate on the same IQueryable, so a request can carry a filter, a search term, a sort order and a page number at once, and the database still receives a single query.
In our simple project, one use case of a search would be to find an owner by his/her name.
Let’s see how we can achieve that.
How Do We Add a Search Query Parameter?
Two changes are enough. Add a Name property to the parameters class the controller binds from the query string, then apply it in the repository before the results are paged.
The parameters class already carries paging and filtering properties, so the new one joins them and binds automatically through [FromQuery]. No controller change is needed at all.
In the repository, the search runs after the filter and before the sort and the page. Order matters here for one reason only: each step narrows the set the next step works on, and all of them are still expression trees at that point, so nothing has hit the database yet.
The whole search is one Where() call. Everything that follows in this article is about the decisions hiding inside it, which comparison, which columns, and what happens when the term is absent.
The embedded video shows an earlier version of this code. The logic is the same, but the sample below has been updated for current .NET, so the method signature and the string comparison differ from what you will see on screen.
We have the infrastructure already, since we covered paging and filtering in the previous parts. We just extend it a bit.
What we want to achieve is something like this:
https://localhost:5001/api/owners?name=Anna Bosh
This should return just one result: Anna Bosh. Of course, the search needs to work together with filtering and paging, so that’s one of the things that we need to keep in mind too.
Like we did with filtering, we’re going to extend our OwnerParameters class first, since we’re going to send our search query as a query parameter:
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;
public string? Name { get; set; }
}
We’ve added just one new property, Name. Because OwnerParameters is bound with [FromQuery], that single line is the whole of the request-side change.
Next, we implement the search itself as an extension method on IQueryable<Owner>, so it returns the narrowed query instead of mutating it:
public static class RepositoryOwnerExtensions
{
public static IQueryable<Owner> Search(this IQueryable<Owner> owners, string? searchTerm)
{
if (string.IsNullOrWhiteSpace(searchTerm))
return owners;
var term = searchTerm.Trim();
return owners.Where(o => o.Name.Contains(term));
}
}
The guard comes first: when the term is missing we hand the query back untouched, because a predicate that matches everything is just work the database does not need.
Then we call it from GetOwners, between the filter and the ordering:
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));
owners = owners.Search(ownerParameters.Name);
var sortedOwners = owners.OrderBy(o => o.Name);
return PagedList<Owner>.ToPagedListAsync(sortedOwners,
ownerParameters.PageNumber,
ownerParameters.PageSize);
}
Searching after filtering means the search predicate runs against a smaller set. Nothing has executed yet, though: every step so far has only added to an expression tree.
One thing this method used to do and no longer does is worth naming. The old version opened with an if (!owners.Any() || string.IsNullOrWhiteSpace(ownerName)) guard, and that Any() call sends SELECT CASE WHEN EXISTS (SELECT 1 FROM [owner] AS [o]) THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END to the database on its own, purely to decide whether to add a Where() clause to a query that has not run yet. It is a round trip that buys nothing, and it is gone.
Is an EF Core String Search Case-Sensitive?
The database decides, not our C# code. Contains() becomes a SQL LIKE, and whether LIKE treats “anna” and “Anna” as the same string is a property of the column’s collation.
SQL Server’s default collation is case-insensitive, which is why the search in this article appears to work without any special handling. That is a default we are relying on rather than a guarantee we asked for, and a database created with a case-sensitive collation behaves differently.
Calling ToLower() on both sides looks like it settles the question. It does not settle it safely: it wraps the column in a function, so the database has to compute the lower-case form of every row before it can compare anything, and any index on that column stops being usable.
The honest options are to rely on the collation deliberately, or to name the collation in the query and make the intent visible.
| Approach | Code | What EF Core sends | Can the index help? |
|---|---|---|---|
| Let the collation decide | o.Name.Contains(term) | [Name] LIKE @p ESCAPE N'\', parameter %term% | No, leading wildcard |
| Force lower-case in C# | o.Name.ToLower().Contains(term.ToLower()) | LOWER([Name]) LIKE @p ESCAPE N'\' | No, the column is wrapped in a function |
| Ask for the pattern explicitly | EF.Functions.Like(o.Name, $"%{term}%") | [Name] LIKE @p, with no ESCAPE clause, so a % or _ the client typed stays a wildcard | No, leading wildcard |
| Name the collation for one query | EF.Functions.Collate(o.Name, "SQL_Latin1_General_CP1_CI_AS").Contains(term) | [Name] COLLATE SQL_Latin1_General_CP1_CI_AS LIKE @p ESCAPE N'\' | No, and COLLATE on the column blocks a seek even for a prefix |
| Prefix search | o.Name.StartsWith(term) | [Name] LIKE @p ESCAPE N'\', parameter term% | Yes |
The SQL in that table is what ToQueryString() prints for each expression on EF Core 10 against SQL Server, with the term held in a variable rather than written as a literal. Two details are easy to miss. EF Core parameterises the whole pattern, wildcards included, so the parameter value is %anna% and the SQL never concatenates a '%'. And EF.Functions.Like is the one variant that gets no ESCAPE clause, so a % or _ the client typed stays a wildcard, which matters when the pattern comes from a search box.
The cost of forcing the issue is not our claim. Microsoft’s EF Core documentation on collations puts a warning on both of the overrides in that table: “Overriding case-sensitivity in a query via EF.Functions.Collate (or by calling string.ToLower) can have a very significant impact on your application’s performance.”
If you want to see this for yourself rather than take our word for it, our articles on how EF Core translates a LIKE pattern and on seeing the SQL EF Core actually generates cover both halves.
Note that this is a database question, not a C# one. For comparing strings case-insensitively in ordinary C#, where no query is involved, the answer is a StringComparison overload and nothing here applies.
How Do We Search Across More Than One Column?
Chain the conditions inside a single Where() with || rather than calling Where() twice. Two calls produce an AND, which asks for rows matching the term in both columns at once, almost never what a search box means.
For the sample project that means one predicate covering Name and Address, so ?name=Road finds owners whose address contains the word even though their name does not.
The parameter name stops fitting at that point. A property called Name that also searches addresses is a lie in the request contract, so it becomes searchTerm, and the query string becomes ?searchTerm=Road.
The guard clause matters more once several columns are involved. When the term is missing we return the query untouched rather than building a predicate that matches everything.
Beyond three or four columns this approach stops scaling. Every column added is another LIKE '%term%' in the same statement, and the database evaluates all of them for every row.
public static IQueryable<Owner> Search(this IQueryable<Owner> owners, string? searchTerm)
{
if (string.IsNullOrWhiteSpace(searchTerm))
return owners;
var term = searchTerm.Trim();
return owners.Where(o => o.Name.Contains(term) || o.Address.Contains(term));
}
The property on OwnerParameters becomes public string? SearchTerm { get; set; }, the call site becomes owners = owners.Search(ownerParameters.SearchTerm);, and requests move from ?name= to ?searchTerm=.
The difference between one Where() and two is not stylistic. Two calls translate to WHERE [o].[Name] LIKE @p ESCAPE N'\' AND [o].[Address] LIKE @p ESCAPE N'\', and against our five sample owners a search for “Road” returns nothing at all, because no owner has “Road” in their name. The single call with || translates to the same statement with OR and returns the two owners whose addresses match.
Testing Our Implementation
The only thing that remains is to test our solution.
First, let’s recall how our database table looks.
Now, let’s try to find Anna:
https://localhost:5001/api/owners?searchTerm=Anna Bosh
Sure enough, we get exactly one owner back, Anna Bosh. Searching for just Anna returns the same single row, because Contains() matches the fragment anywhere in the value, and the column’s case-insensitive collation means ANNA works too.
For an additional example, let’s try to find all the owners that contain the letter “o”:
https://localhost:5001/api/owners?searchTerm=o
Now we get four results instead of one, and one of them matched on its address rather than its name:
[
{
"id": "261e1685-cf26-494c-b17c-3546e65f5620",
"name": "Anna Bosh",
"dateOfBirth": "1974-11-14T00:00:00",
"address": "27 Colored Row"
},
{
"id": "24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
"name": "John Keen",
"dateOfBirth": "1980-12-05T00:00:00",
"address": "61 Wellfield Road"
},
{
"id": "66774006-2371-4d5b-8518-2177bcf3f73e",
"name": "Nick Somion",
"dateOfBirth": "1998-12-15T00:00:00",
"address": "North sunny address 102"
},
{
"id": "a3c1880c-674c-4d18-8f91-5d3608a2c937",
"name": "Sam Query",
"dateOfBirth": "1990-04-22T00:00:00",
"address": "91 Western Roads"
}
]
Now let’s combine that with filtering and paging:
https://localhost:5001/api/owners?searchTerm=o&minYearOfBirth=1974&maxYearOfBirth=1985&pageSize=1&pageNumber=2
Can you guess which result we should get (hint, it’s a single result)? If you’ve guessed, leave us a comment in the comments section.
That’s it, we’ve successfully implemented and tested our search functionality.
When Is a LIKE Search No Longer Enough?
A LIKE '%term%' search is right up to the point where it stops being fast or stops being accurate, and those limits arrive from different directions.
Speed goes first. A leading wildcard means every row in the table is examined, so the query degrades in proportion to table size regardless of what indexes exist.
Accuracy goes second, and more quietly. LIKE has no notion of word stems, synonyms, misspellings or relevance ranking, so a search for “running” misses “run” and every match is equally good as far as the API is concerned.
The replacements are not interchangeable. A database’s own full-text index fixes stemming and ranking without leaving the database. A dedicated search engine adds relevance tuning, faceting and analysers at the cost of a second system to run and keep in sync.
The threshold is worth naming plainly. Stay with LIKE while the table is small; move on once readers type phrases and expect the API to understand them.
When that day comes, the two routes we have written up are a dedicated search engine like Elasticsearch and a full-text index you host yourself with Lucene.NET. Both are a bigger commitment than the six lines above, which is exactly why the boundary is worth knowing before you cross it.
The search we built here is deliberately the simple case; the Ultimate ASP.NET Core Web API course builds the same query pipeline against a service layer with validation, async repositories and tests around it.
Conclusion
In this article we’ve covered:
- What searching is and how it’s different from filtering
- How to add a search query parameter to an ASP.NET Core Web API
- Whether an EF Core string search is case-sensitive, and what that costs
- How to search across more than one column with a single predicate
- When a LIKE search stops being the right tool
Hope this article was useful to you. If you have any questions or suggestions, please leave us a comment and we’ll get to you as quickly as possible.
Next up, we’ll cover sorting.
Tested with .NET 10.0.10, EF Core 10.0.11, and SQL Server Express LocalDB 13.0.4001.0.

