Most ASP.NET Core APIs use familiar HTTP methods such as GET, POST, PUT, PATCH, and DELETE.
For search endpoints, GET is usually the first choice.
That works well when the search can be represented by a reasonable set of query-string parameters:
GET /products?category=laptop&brand=dell&minPrice=50000
But search requests can become much more complicated.
A real application might need filters, nested conditions, sorting rules, pagination, date ranges, field selection, and multiple values. Putting all of that into a URL can make the request difficult to read and difficult to maintain.
This is where the HTTP QUERY method becomes interesting.
ASP.NET Core 11 adds OpenAPI support for describing QUERY operations, while OpenAPI 3.2 introduces a standard query operation field for this purpose. The method is intended for safe, idempotent requests that need to send query information in the request body rather than putting everything into the URL.
This article shows how to create a QUERY endpoint in ASP.NET Core 11, generate its OpenAPI description, and understand when this approach makes sense.
Why Do We Need HTTP QUERY?
Consider a product search API.
A simple search might look like this:
GET /products?category=laptop&brand=lenovo
There is nothing wrong with that.
Now imagine the application supports:
Multiple categories
Multiple brands
Minimum and maximum prices
Date ranges
Stock conditions
Customer-specific filters
Nested AND/OR conditions
Sorting by multiple fields
Pagination
Field selection
A request could become very long:
GET /products?category=laptop&category=desktop&brand=lenovo&brand=dell&minPrice=50000&maxPrice=150000&sort=price&order=desc&page=3...
It can still work, but the URL becomes difficult to manage.
A QUERY request allows the search criteria to be represented as a request body instead:
QUERY /products
Content-Type: application/json
{
"categories": ["laptop", "desktop"],
"brands": ["Lenovo", "Dell"],
"price": {
"min": 50000,
"max": 150000
},
"sort": [
{
"field": "price",
"direction": "desc"
}
],
"page": 3
}
This is much easier to represent when the search model becomes complex.
QUERY Is Not the Same as POST
At first glance, a developer may ask:
Why not just use POST for the search?
That is a reasonable question.
The difference is the meaning of the operation.
POST is generally used for operations where the request may create a resource or trigger processing with side effects.
QUERY is intended for safe, idempotent querying.
For example:
POST /orders/search
can certainly work as an application design.
But semantically, the request is still a query.
The QUERY method gives clients and API documentation a way to express that meaning directly.
A simplified comparison looks like this:
Method | Typical purpose | Request body | Safe | Idempotent |
|---|---|---|---|---|
GET | Retrieve data | Usually no | Yes | Yes |
POST | Create/process | Yes | No | Usually no |
PUT | Replace resource | Yes | No | Yes |
PATCH | Partially update | Yes | No | Not necessarily |
QUERY | Complex retrieval/query | Yes | Yes | Yes |
The exact behavior of an application still depends on how the endpoint is implemented.
A QUERY endpoint should not modify application state just because the method itself is intended to be safe.
QUERY and OpenAPI 3.2
This is where ASP.NET Core 11 adds an important improvement.
OpenAPI 3.2 defines a query operation alongside the familiar operations:
{
"get": {},
"post": {},
"put": {},
"patch": {},
"delete": {},
"query": {}
}
Earlier OpenAPI versions do not have this standard operation field.
ASP.NET Core 11 therefore treats QUERY differently depending on the OpenAPI version being generated.
With OpenAPI 3.2:
{
"paths": {
"/search": {
"query": {
"requestBody": {},
"responses": {}
}
}
}
}
With OpenAPI 3.0 or 3.1, ASP.NET Core uses the x-oai-additionalOperations extension instead.
This distinction is important if your API documentation is consumed by other tools.
Creating a QUERY Endpoint
ASP.NET Core routing already allows arbitrary HTTP method strings through MapMethods.
That means you can create a QUERY endpoint using:
app.MapMethods(
"/search",
["QUERY"],
(SearchRequest request) =>
SearchService.Run(request));
A complete minimal application can look like this:
using Microsoft.OpenApi;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi(options =>
{
options.OpenApiVersion =
OpenApiSpecVersion.OpenApi3_2;
});
var app = builder.Build();
app.MapOpenApi();
app.MapMethods(
"/search",
["QUERY"],
(SearchRequest request) =>
{
return Results.Ok(
SearchService.Run(request));
});
app.Run();
The important part is:
app.MapMethods(
"/search",
["QUERY"],
...
);
MapMethods lets you specify the HTTP methods explicitly.
Creating the Search Request Model
The request body can be represented using a normal C# model.
For example:
public sealed class SearchRequest
{
public string[]? Categories { get; set; }
public string[]? Brands { get; set; }
public decimal? MinPrice { get; set; }
public decimal? MaxPrice { get; set; }
public string? SortBy { get; set; }
public string? SortDirection { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 25;
}
The endpoint can then accept this model:
app.MapMethods(
"/products/search",
["QUERY"],
(SearchRequest request) =>
{
var products = SearchProducts(request);
return Results.Ok(products);
});
This gives the request a clear structure instead of forcing the client to encode every condition into the URL.
Generating the OpenAPI Document
ASP.NET Core includes built-in OpenAPI document generation.
Register it with:
builder.Services.AddOpenApi(options =>
{
options.OpenApiVersion =
OpenApiSpecVersion.OpenApi3_2;
});
Then map the document endpoint:
app.MapOpenApi();
By default, the generated document is exposed through an endpoint based on the /openapi/{documentName}.json pattern.
When the application generates an OpenAPI 3.2 document, the QUERY operation can be represented directly as:
{
"paths": {
"/products/search": {
"query": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SearchRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success"
}
}
}
}
}
}
This is much better than leaving the operation undocumented or hiding it behind a custom extension.
Why OpenAPI 3.2 Matters Here
The biggest reason to explicitly generate OpenAPI 3.2 is simple.
QUERY is a first-class operation in OpenAPI 3.2.
That means tools that understand OpenAPI 3.2 can inspect the endpoint without needing a custom extension.
Compare the two representations.
OpenAPI 3.2:
"/search": {
"query": {
"requestBody": {},
"responses": {}
}
}
Older OpenAPI versions:
"/search": {
"x-oai-additionalOperations": {
"QUERY": {
"requestBody": {},
"responses": {}
}
}
}
ASP.NET Core 11 generates the latter representation for OpenAPI 3.0 and 3.1.
If your API consumers are ready for OpenAPI 3.2, using the newer specification gives you a cleaner contract.
ASP.NET Core 11 Changes the Default OpenAPI Version
There is another change developers should know about.
In ASP.NET Core 11, the default OpenApiVersion changed to OpenAPI 3.2.
Previously, AddOpenApi() generated OpenAPI 3.1 by default.
Starting with ASP.NET Core 11 Preview 6, the default is:
OpenApiSpecVersion.OpenApi3_2
This is a behavioral breaking change.
For a new application, this is convenient.
For an existing application, however, it is worth checking what downstream tools expect.
If your API consumers only support OpenAPI 3.1, you can explicitly configure the older version:
builder.Services.AddOpenApi(options =>
{
options.OpenApiVersion =
OpenApiSpecVersion.OpenApi3_1;
});
This makes the contract explicit rather than depending on the framework default.
A More Realistic Search Model
For a real application, a flat request model may not be enough.
Consider an e-commerce search:
public sealed class ProductSearchRequest
{
public FilterGroup? Filters { get; set; }
public SortOption[]? Sort { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 25;
}
public sealed class FilterGroup
{
public FilterCondition[]? Conditions { get; set; }
public string Operator { get; set; } = "and";
}
public sealed class FilterCondition
{
public string Field { get; set; } = string.Empty;
public string Operator { get; set; } = string.Empty;
public string? Value { get; set; }
}
public sealed class SortOption
{
public string Field { get; set; } = string.Empty;
public string Direction { get; set; } = "asc";
}
A request could then look like:
{
"filters": {
"operator": "and",
"conditions": [
{
"field": "category",
"operator": "equals",
"value": "laptop"
},
{
"field": "price",
"operator": "lessThan",
"value": "150000"
},
{
"field": "stock",
"operator": "greaterThan",
"value": "0"
}
]
},
"sort": [
{
"field": "price",
"direction": "asc"
}
],
"page": 1,
"pageSize": 25
}
This is difficult to express cleanly in a URL.
A request body is a much better fit for this kind of structure.
QUERY Can Still Be Safe
The fact that a QUERY request has a body does not make it unsafe.
The application determines whether the endpoint has side effects.
For example:
app.MapMethods(
"/products/search",
["QUERY"],
async (ProductSearchRequest request) =>
{
var result =
await productService.SearchAsync(request);
return Results.Ok(result);
});
The endpoint only reads data.
There is no database update:
await db.Products
.Where(...)
.ToListAsync();
That matches the intended safe-query semantics.
By contrast, this would be a poor use of QUERY:
app.MapMethods(
"/orders/process",
["QUERY"],
async (OrderRequest request) =>
{
await orderService.ProcessPaymentAsync(request);
return Results.Ok();
});
Processing a payment changes application state.
That belongs under a method designed for a state-changing operation, such as POST.
QUERY Is Useful for Large Search Criteria
URL length limits are another practical reason to consider this method.
There is no single universal URL-length limit across browsers, proxies, servers, and infrastructure.
A complex query can become problematic when encoded entirely into the URL.
For example:
GET /search?filter1=...&filter2=...&filter3=...&filter4=...
As the number of filters grows, the URL becomes harder to handle.
A QUERY request moves the structured data into the request body:
QUERY /search
Content-Type: application/json
{
"filters": [...],
"sort": [...],
"pagination": {...}
}
That makes the API contract easier to model.
However, developers should not assume that every infrastructure component already treats QUERY exactly like GET.
Reverse proxies, firewalls, API gateways, logging systems, monitoring tools, and client libraries may have different levels of support.
Always test the complete request path.
QUERY and Caching
Caching is another area that requires attention.
Developers are accustomed to caching GET requests.
For example:
GET /products?page=1
can have a straightforward cache key based on the URL.
With QUERY, the search criteria may be in the request body:
QUERY /products
{
"category": "laptop"
}
That changes how caching needs to be handled.
If your application needs caching for QUERY, decide explicitly:
What identifies a unique request?
Does the cache include the request body?
Does the gateway support caching
QUERY?Is the request body normalized?
Are equivalent search objects represented consistently?
Do not assume that an existing GET caching configuration automatically applies.
QUERY and Idempotency
A safe query should return the same logical result for the same application state and request.
For example:
QUERY /products
{
"category": "laptop"
}
can be repeated:
Request 1 → Search products
Request 2 → Search products
Request 3 → Search products
without changing the database.
This is what makes the method appropriate for querying.
But idempotency does not mean the response must always be byte-for-byte identical.
Products can change between requests.
A new product might be added, inventory might change, or the search index might be updated.
The important point is that performing the query does not itself modify the resource state.
Client Support Is Important
Before exposing a QUERY endpoint publicly, check how your clients send it.
A modern HTTP client may allow:
using var request = new HttpRequestMessage(
HttpMethod.Query,
"/products/search");
However, support for a newer HTTP method can vary between libraries, frameworks, API gateways, and generated clients.
For internal services where you control both sides, this is easier.
For public APIs, compatibility testing becomes more important.
You should test:
Browser clients
Mobile applications
.NET clients
JavaScript clients
API gateways
Reverse proxies
Load balancers
Monitoring systems
API testing tools
The server supporting QUERY does not automatically mean every client in the ecosystem supports it correctly.
OpenAPI and Client Generation
One reason the OpenAPI representation matters is code generation.
A typical API workflow looks like:
ASP.NET Core API
|
v
OpenAPI document
|
+---- Client generator
|
+---- API documentation
|
+---- Validation
|
+---- Testing tools
If a tool understands OpenAPI 3.2 and the query operation, it can potentially understand the endpoint as part of the API contract.
But this is where you should be careful.
Do not assume that every OpenAPI tool already understands QUERY.
OpenAPI 3.2 is newer than the versions supported by many existing API tools.
Test the generated document with the exact tools your team uses.
What If Your Tool Only Supports OpenAPI 3.1?
You have two practical choices.
The first is to continue generating OpenAPI 3.1:
builder.Services.AddOpenApi(options =>
{
options.OpenApiVersion =
OpenApiSpecVersion.OpenApi3_1;
});
ASP.NET Core can still describe the QUERY operation using:
"x-oai-additionalOperations"
instead of the standard query field.
The second option is to move your tooling to versions that understand OpenAPI 3.2.
The right choice depends on your ecosystem.
For an internal application, upgrading the tooling may be straightforward.
For a public API with many consumers, changing the OpenAPI version may require more planning.
OpenAPI 3.2 Is More Than QUERY
Although QUERY is the focus here, OpenAPI 3.2 contains other improvements.
ASP.NET Core 11 also uses the newer OpenAPI dependency and supports additional capabilities introduced by the specification.
For example, the ASP.NET Core 11 release notes mention support for item schemas for streaming events and improved descriptions of binary file responses.
This is one reason ASP.NET Core 11 moves its built-in OpenAPI support to the newer specification.
Still, you should not upgrade to OpenAPI 3.2 just because it is newer.
Check your entire API tooling chain first.
Testing the QUERY Endpoint
A simple test can be performed with an HTTP client capable of sending a custom method.
For example, using curl:
curl -X QUERY \
https://localhost:5001/products/search \
-H "Content-Type: application/json" \
-d '{
"categories": ["laptop"],
"brands": ["Dell", "Lenovo"],
"minPrice": 50000,
"maxPrice": 150000,
"page": 1,
"pageSize": 25
}'
The server receives the request body and binds it to:
ProductSearchRequest request
The endpoint can then execute the search normally.
The key difference is the HTTP method and the way the search criteria are transported.
Logging QUERY Requests
Do not forget to update your observability setup.
If your logs assume that all read operations are GET, a new QUERY endpoint may not appear correctly in dashboards or metrics.
For example, your metrics should distinguish:
GET /products
QUERY /products/search
POST /products
This makes it easier to understand how clients are actually using the API.
Also verify that request-body logging does not accidentally capture sensitive search data.
Complex query objects can contain:
Customer identifiers
Internal IDs
Account information
Sensitive filters
Business data
Logging everything just because the request body is useful during development can create a separate security problem.
When Should You Use QUERY?
QUERY is not something that should replace every GET.
Use GET when the request is simple and naturally fits the URL:
GET /products/123
or:
GET /products?category=laptop&page=2
Consider QUERY when the search itself is complex:
QUERY /products/search
with a structured body containing nested filters, sorting, pagination, and other search criteria.
A useful rule is:
Simple retrieval
↓
GET
Complex safe query
↓
QUERY
State-changing operation
↓
POST / PUT / PATCH / DELETE
This keeps the API semantics easier to understand.
Common Mistakes
Using QUERY for State Changes
Do not use QUERY to create, update, or delete resources.
The method is intended for safe querying.
Assuming Every Client Supports It
Server support does not guarantee client or infrastructure support.
Test the entire request path.
Forgetting OpenAPI Version
If you want the native query operation in the generated OpenAPI document, use OpenAPI 3.2.
builder.Services.AddOpenApi(options =>
{
options.OpenApiVersion =
OpenApiSpecVersion.OpenApi3_2;
});
Assuming GET Caching Automatically Works
Request-body-based queries can require different cache handling.
Ignoring Existing API Consumers
Changing an existing search endpoint from GET to QUERY can break clients.
Consider introducing a new endpoint rather than changing an established public contract.
Treating OpenAPI 3.2 Support as Universal
Your API generator may understand OpenAPI 3.2 while another downstream tool does not.
Validate the actual generated document with your toolchain.
A Complete Minimal Example
Here is a compact example that brings the main pieces together:
using Microsoft.OpenApi;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi(options =>
{
options.OpenApiVersion =
OpenApiSpecVersion.OpenApi3_2;
});
var app = builder.Build();
app.MapOpenApi();
app.MapMethods(
"/products/search",
["QUERY"],
(ProductSearchRequest request) =>
{
var results = new[]
{
new
{
Id = 1,
Name = "Laptop",
Price = 75000
},
new
{
Id = 2,
Name = "Desktop",
Price = 95000
}
};
return Results.Ok(results);
});
app.Run();
public sealed class ProductSearchRequest
{
public string[]? Categories { get; set; }
public string[]? Brands { get; set; }
public decimal? MinPrice { get; set; }
public decimal? MaxPrice { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 25;
}
The important pieces are easy to identify:
builder.Services.AddOpenApi(options =>
{
options.OpenApiVersion =
OpenApiSpecVersion.OpenApi3_2;
});
and:
app.MapMethods(
"/products/search",
["QUERY"],
...
);
That is enough to create a route using the QUERY HTTP method and describe it in an OpenAPI 3.2 document.
Production Checklist
Before using QUERY in a production API, check:
Is the operation genuinely safe?
Is the operation idempotent?
Is the search complex enough to justify a request body?
Do your HTTP clients support
QUERY?Does your reverse proxy support it?
Does your API gateway support it?
Does your monitoring system recognize it?
Does your OpenAPI tooling support 3.2?
Do your client generators understand the
queryoperation?Do your caching rules account for the request body?
Are sensitive query values excluded from logs?
Have existing API consumers been considered?
This checklist is more important than simply getting the endpoint to work locally.
Summary
ASP.NET Core 11 makes it easier to work with the HTTP QUERY method and OpenAPI 3.2. The method is useful for safe, idempotent searches where the request is too large or too structured to represent comfortably through normal URL query parameters.
The implementation is straightforward. ASP.NET Core routing can map the method using MapMethods, while built-in OpenAPI generation can describe it when the document uses OpenAPI 3.2.
The biggest advantage is a cleaner API contract for complex searches. Instead of putting a large collection of filters into a URL, the client can send a structured JSON request body.
That does not mean QUERY should replace GET. Simple reads are still a good fit for GET, while QUERY becomes useful when the search model gets complicated.
The other important point is compatibility. OpenAPI 3.2 is newer, and not every API client, gateway, or documentation tool will necessarily understand QUERY yet. Test the complete ecosystem before introducing it to a public API.
For new APIs with complex read-only search requirements, however, QUERY gives ASP.NET Core developers a cleaner option than forcing every search into either a huge URL or a semantically misleading POST.
Join the conversation! Your thoughts help the community grow.