You have a search API. Users need to filter by category, date range, sort order, pagination, and a freeform text query. You could pack all of that into a query string, but the URL balloons past what some proxies and browsers will tolerate. So you reach for POST -- but now your "read" endpoint is neither safe nor idempotent, intermediaries cannot cache it, and clients cannot safely retry it on failure.

Every API developer has been here. GET cannot carry a request body with defined semantics. POST can, but it throws away every guarantee that makes read operations predictable. For years, the HTTP specification left us choosing between two bad options for complex queries.

That changes with the HTTP QUERY method, now approaching final publication as an IETF Proposed Standard. And as of .NET 11 Preview 4, ASP.NET Core recognises it as a first-class citizen in OpenAPI document generation.

What QUERY actually is

QUERY is a new HTTP method defined in draft-ietf-httpbis-safe-method-w-body, approved by the IESG in November 2025 as a Proposed Standard. It is awaiting final RFC number assignment from the RFC Editor.

The method has three properties that matter:

In other words, QUERY is GET with a body. The server treats the request body as query criteria and returns matching results, just as GET uses the URI to identify a resource. The difference is that QUERY's criteria live in a structured request body rather than a percent-encoded query string.

QUERY /api/products HTTP/1.1
Host: shop.example.com
Content-Type: application/json
Accept: application/json

{
  "category": "electronics",
  "priceRange": { "min": 50, "max": 500 },
  "inStock": true,
  "sort": ["-rating", "price"],
  "page": 1,
  "pageSize": 25
}

The response follows standard HTTP semantics -- 200 for success, 415 if the server does not support the content type, 422 if the query is syntactically valid but semantically broken.

Why not just use POST?

The distinction is not academic. POST for read operations breaks real things:

Caching vanishes. HTTP caches -- CDNs, reverse proxies, browser caches -- do not cache POST responses by default. Every identical search hits your server. QUERY responses are cacheable, and the specification defines how caches should incorporate the request body into the cache key.

Retries become dangerous. When a network blip kills a POST request mid-flight, clients cannot safely retry it because POST is not idempotent. Load balancers and service meshes that automatically retry failed GET requests will not do the same for POST. QUERY, being idempotent, can be retried freely.

Semantics lie to consumers. A POST endpoint that reads data forces API consumers to understand your implementation. They cannot know from the method alone whether calling the endpoint will change state. QUERY makes the intent explicit: this is a read operation, full stop.

Preflight overhead in browsers. Both POST with non-simple content types and QUERY trigger CORS preflight requests, so they are equivalent here. But QUERY at least gives you correct semantics in exchange for that overhead, whereas POST gives you incorrect semantics for the same cost.

ASP.NET Core support in .NET 11

ASP.NET Core has been building QUERY support across several previews. Here is what is available today.

The HttpMethods.Query constant

The HttpMethods class in Microsoft.AspNetCore.Http now includes a Query constant and an IsQuery helper, sitting alongside the existing Get, Post, Put, and friends:

Example.cs
// Already available in the framework
string method = HttpMethods.Query; // "QUERY"
bool isQuery = HttpMethods.IsQuery(request.Method);

This was merged via PR #63260 and means that ASP.NET Core's routing infrastructure recognises QUERY as a known method.

Defining QUERY endpoints with minimal APIs

ASP.NET Core's MapMethods has always accepted arbitrary HTTP method strings, so you could technically use QUERY before Preview 4. What Preview 4 adds is OpenAPI document generation support -- your QUERY endpoints now appear correctly in generated OpenAPI specifications.

Program.cs
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("/api/products/search", ["QUERY"], (ProductSearchRequest request) =>
{
    var results = ProductCatalogue.Search(request);
    return TypedResults.Ok(results);
});

app.Run();

The ProductSearchRequest record is automatically deserialised from the request body, just as it would be with a POST endpoint:

Models/ProductSearchRequest.cs
public record ProductSearchRequest(
    string? Category,
    PriceRange? PriceRange,
    bool? InStock,
    string[]? Sort,
    int Page = 1,
    int PageSize = 25);

public record PriceRange(decimal Min, decimal Max);

OpenAPI 3.2 output

When targeting OpenAPI 3.2, QUERY appears as a first-class operation alongside get, post, and the rest:

openapi.json
{
  "openapi": "3.2.0",
  "paths": {
    "/api/products/search": {
      "query": {
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProductSearchRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    }
  }
}

// NOTE

OpenAPI 3.2 is required for native QUERY support. If you target OpenAPI 3.0 or 3.1, ASP.NET Core falls back to an x-oai-additionalOperations extension to represent the QUERY operation.

Building a convenience MapQuery extension

Since MapMethods requires you to pass the method string every time, a thin extension method cleans things up:

Extensions/HttpQueryExtensions.cs
public static class HttpQueryExtensions
{
    public static IEndpointConventionBuilder MapQuery(
        this IEndpointRouteBuilder endpoints,
        string pattern,
        Delegate handler)
    {
        return endpoints.MapMethods(pattern, ["QUERY"], handler);
    }
}

Now your route registrations read naturally:

Program.cs
app.MapQuery("/api/products/search", (ProductSearchRequest request) =>
{
    var results = ProductCatalogue.Search(request);
    return TypedResults.Ok(results);
});

app.MapQuery("/api/orders/search", (OrderSearchRequest request) =>
{
    var results = OrderRepository.Search(request);
    return TypedResults.Ok(results);
});

// TIP

A built-in MapQuery extension and [HttpQuery] attribute have been proposed in the API review (issue #61089) and may land in a later .NET 11 preview. For now, the three-line extension above does the job.

Using QUERY in MVC controllers

For controllers, you can create a custom HttpQueryAttribute that follows the same pattern as HttpGetAttribute and HttpPostAttribute:

Attributes/HttpQueryAttribute.cs
public class HttpQueryAttribute : HttpMethodAttribute
{
    private static readonly IEnumerable<string> SupportedMethods = ["QUERY"];

    public HttpQueryAttribute() : base(SupportedMethods) { }

    public HttpQueryAttribute([StringSyntax("Route")] string template)
        : base(SupportedMethods, template) { }
}

Then use it exactly as you would any other HTTP method attribute:

Controllers/ProductsController.cs
[ApiController]
[Route("api/products")]
public class ProductsController(ProductCatalogue catalogue) : ControllerBase
{
    [HttpQuery("search")]
    public IActionResult Search([FromBody] ProductSearchRequest request)
    {
        var results = catalogue.Search(request);
        return Ok(results);
    }
}

Calling QUERY endpoints from .NET

HttpClient has always supported custom HTTP methods via HttpRequestMessage, so consuming QUERY endpoints requires no new APIs:

Services/ProductSearchClient.cs
public class ProductSearchClient(HttpClient httpClient)
{
    public async Task<ProductSearchResponse?> SearchAsync(
        ProductSearchRequest request,
        CancellationToken ct = default)
    {
        var message = new HttpRequestMessage(
            new HttpMethod("QUERY"),
            "/api/products/search")
        {
            Content = JsonContent.Create(request)
        };

        var response = await httpClient.SendAsync(message, ct);
        response.EnsureSuccessStatusCode();

        return await response.Content
            .ReadFromJsonAsync<ProductSearchResponse>(ct);
    }
}

A typed extension method can reduce the boilerplate:

Extensions/HttpClientQueryExtensions.cs
public static class HttpClientQueryExtensions
{
    private static readonly HttpMethod QueryMethod = new("QUERY");

    public static async Task<TResponse?> QueryAsJsonAsync<TRequest, TResponse>(
        this HttpClient client,
        string requestUri,
        TRequest request,
        CancellationToken ct = default)
    {
        using var message = new HttpRequestMessage(QueryMethod, requestUri)
        {
            Content = JsonContent.Create(request)
        };

        var response = await client.SendAsync(message, ct);
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<TResponse>(ct);
    }
}

Which simplifies call sites to a single line:

Example.cs
var results = await httpClient.QueryAsJsonAsync<ProductSearchRequest, ProductSearchResponse>(
    "/api/products/search", searchRequest, ct);

The Accept-Query header

The QUERY specification introduces a new response header -- Accept-Query -- that lets servers advertise which content types they accept for query bodies:

HTTP/1.1 200 OK
Accept-Query: application/json, application/x-www-form-urlencoded
Content-Type: application/json

{ "results": [...] }

This is analogous to Accept-Patch for PATCH requests. Clients can inspect this header to discover what format the server expects without consulting out-of-band documentation. If you are building a discoverable API, returning this header from your QUERY endpoints is worth the minimal effort.

In ASP.NET Core, you can add it through middleware or a result filter:

Filters/AcceptQueryHeaderFilter.cs
public class AcceptQueryHeaderFilter : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var result = await next(context);
        context.HttpContext.Response.Headers["Accept-Query"] = "application/json";
        return result;
    }
}

Ecosystem support beyond .NET

QUERY is not limited to .NET. Most HTTP tooling already handles custom methods:

curl:

terminal
curl -X QUERY https://api.example.com/products/search \
  -H "Content-Type: application/json" \
  -d '{"category": "electronics", "inStock": true}'

JavaScript Fetch API:

script.js
const response = await fetch('/api/products/search', {
  method: 'QUERY',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ category: 'electronics', inStock: true })
});

Postman has supported arbitrary method strings since version 6.5 -- type "QUERY" in the method dropdown.

// WARNING

QUERY is not a CORS-safelisted method. Cross-origin requests from browsers will trigger a preflight OPTIONS request. Your server must include QUERY in the Access-Control-Allow-Methods response header.

Real-world use cases

QUERY shines wherever you have complex, structured read criteria:

Faceted search. E-commerce product search with nested filters, facet selections, price ranges, and sort options that would produce absurdly long query strings.

Reporting APIs. Business intelligence queries with multiple dimensions, measures, date ranges, and grouping expressions. These are fundamentally read operations, but the query definitions are too complex for URI parameters.

GraphQL over HTTP. The GraphQL community has long used POST for queries because GET cannot carry the query document. QUERY is semantically correct for GraphQL reads -- it is safe, idempotent, and cacheable:

QUERY /graphql HTTP/1.1
Content-Type: application/graphql

{
  products(category: "electronics", inStock: true) {
    id
    name
    price
    rating
  }
}

Geospatial search. GIS queries where the search criteria include GeoJSON polygons or multi-point boundaries that are impractical to encode in a URL.

Common pitfalls

Using POST out of habit. The biggest pitfall is not adopting QUERY at all. If your API has POST endpoints that only read data, they are candidates for migration. Start with new endpoints and migrate existing ones as client support matures.

Forgetting OpenAPI 3.2. If you generate OpenAPI documents targeting 3.0 or 3.1, your QUERY endpoints will not appear as query operations. They will fall back to the x-oai-additionalOperations extension, which most tooling does not understand yet. Explicitly set OpenApiSpecVersion.OpenApi3_2 if you want clean output.

Ignoring Content-Type validation. The QUERY specification requires servers to reject requests where Content-Type is missing or unsupported. ASP.NET Core's model binding handles this automatically for JSON, but if you are reading the body manually, check the content type and return 415 when it does not match.

Assuming all intermediaries support QUERY. Some older proxies, API gateways, and WAFs may not recognise QUERY and could reject or misroute it. Test your entire request path before deploying. If you are behind a gateway that strips request bodies from non-POST methods, QUERY will not work until the gateway is updated.

Caching without considering the body. QUERY responses are cacheable, but the cache key must incorporate the request body. Standard HTTP caches are still catching up here. Do not assume CDN-level caching will work out of the box -- you may need application-level caching initially.

Not returning Accept-Query. If your endpoint supports specific content types for query bodies, advertise them with the Accept-Query header. Clients and tooling will increasingly rely on this for discovery.

Summary