dotnet

HTTP QUERY in .NET 10: the read verb that finally takes a body

Harshit · 16 Jul 2026, 5 min read

Watch on YouTube

Picture an admin dashboard for a wholesaler. Every morning the ops team pastes in a list of SKUs and expects current prices back for their report. It is the most ordinary read endpoint imaginable, and it is exactly where HTTP's verb vocabulary has been letting us down for thirty years.

This post walks through the three-chapter demo from the video. All the code lives in the HttpQuery repo if you want to run it yourself (.NET 10 SDK required).

Chapter 1: GET works until the morning report arrives

The obvious design puts the IDs in the query string:

GET /api/v1/products/prices?ids=1,2,3,10,11

Small lookups are fine. Then someone pastes the full morning report of 1,000 SKUs, the URL grows to roughly 9 KB, and Kestrel's default MaxRequestLineSize of 8 KB rejects the request line before your controller even runs:

HTTP/1.1 414 URI Too Long

You cannot fix this by raising the limit, because you do not control every hop. Most reverse proxies cap the request line at a few kilobytes in production, so somewhere between you and the client the big GET dies.

The tempting hack: GET with a body

The next idea everyone has: keep GET, put the IDs in a request body. HTTP does not exactly forbid this, and that is the trap. RFC 9110 says content on a GET request has "no generally defined semantics," which in the real world is worse than forbidden:

  • CDNs and API gateways are free to strip the body before forwarding, and many do. Your origin receives a GET with no filter and happily returns the wrong response.
  • Caches build their key from the method and URL only. The body is invisible to them, so two different lookups can collide on one cache entry and users start receiving each other's results.
  • Plenty of clients refuse to cooperate at all: the browser fetch API throws if you attach a body to a GET.

Elasticsearch is the famous case study. Its search API accepted GET with a body for years, and it had to also accept POST for the same endpoints precisely because so many proxies and clients mangle bodied GETs. When the tool most associated with the pattern needs an escape hatch, the pattern is not viable.

Chapter 2: POST works, but it is a lie

The classic workaround moves the list into a JSON body:

POST /api/v2/products/prices
Content-Type: application/json

{ "ids": [1, 2, 3, ...] }

No more length limit, and nothing strips the body, because POST bodies are load-bearing everywhere. But POST tells every intermediary, cache, and retry library the wrong story. POST is neither safe nor idempotent, which means:

  • The response cannot be cached in any useful way. Shared caches key on method plus URL, and to a cache POST means "this changes something, pass it through." Every identical morning report hits your origin and reruns the same lookup, even though prices for the same ID list are a perfect caching candidate. Slapping Cache-Control: public on a POST response does not fix it; caches have no body-aware key to store it under.
  • No sane HTTP client will automatically retry a failed POST, because for all it knows the request charged a credit card. Your read endpoint loses transparent retry for free.
  • Anyone reading your API surface sees a mutation where a read lives.

Chapter 3: QUERY, the verb that matches reality

RFC 10008 defines the QUERY method: it carries a request body like POST, and it is declared safe and idempotent like GET. Same wire format as the POST version, different semantics:

QUERY /api/v3/products/prices
Content-Type: application/json

{ "ids": [1, 2, 3, ...] }

Caches may store it (keyed on the body), retry policies may repeat it, and the verb documents the intent: this is a read.

What .NET 10 actually ships

This part matters if you try to use it today. Via aspnetcore#61089, .NET 10 ships the plumbing:

  • HttpMethods.Query, the string constant, plus HttpMethods.IsQuery for middleware, on the server side.
  • HttpMethod.Query on System.Net.Http for clients.

What it deliberately held back, pending community feedback: the MVC [HttpQuery] attribute and the minimal-API MapQuery extension. Until those land, a ten-line shim on top of HttpMethodAttribute fills the gap:

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class HttpQueryAttribute : HttpMethodAttribute
{
    private static readonly IEnumerable<string> SupportedMethods =
        [Microsoft.AspNetCore.Http.HttpMethods.Query];

    public HttpQueryAttribute()
        : base(SupportedMethods) { }

    public HttpQueryAttribute(string template)
        : base(SupportedMethods, template) { }
}

Because it references HttpMethods.Query rather than a magic string, the day Microsoft ships the real attribute you delete this file and swap the attribute name, nothing else changes.

The controller

With the shim in place, the endpoint reads like any other action, plus one line that GET-style semantics newly earn us:

[ApiController]
[Route("api/v3/products")]
public sealed class ProductsQueryController : ControllerBase
{
    private readonly IProductCatalog _catalog;

    public ProductsQueryController(IProductCatalog catalog) => _catalog = catalog;

    [HttpQuery("prices")]
    public ActionResult<PriceLookupResponse> LookupPrices([FromBody] PriceLookupRequest request)
    {
        if (request.Ids is null || request.Ids.Length == 0)
            return BadRequest("ids must be a non-empty array.");

        Response.Headers.CacheControl = "public, max-age=60";

        return Ok(_catalog.LookupPrices(request.Ids));
    }
}

Setting Cache-Control: public on a POST response would be asking for trouble. On a QUERY response it is the whole point.

Calling it from HttpClient

The raw version uses HttpMethod.Query directly:

using var msg = new HttpRequestMessage(HttpMethod.Query, "/api/v3/products/prices")
{
    Content = JsonContent.Create(request),
};

using var response = await http.SendAsync(msg);
response.EnsureSuccessStatusCode();

var prices = await response.Content.ReadFromJsonAsync<PriceLookupResponse>();

In a real codebase you want the QUERY analogue of PostAsJsonAsync, which is a small extension method:

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

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

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

The payoff shows up in infrastructure code. A retry helper can key off safety instead of guessing: QUERY joins GET, HEAD, OPTIONS, and TRACE in the safe set, so transient failures get retried with backoff, while POST is sent exactly once because nobody proved it is idempotent. That logic is in the demo client as SendWithSafeRetryAsync.

Do not rip out GET

QUERY is for reads whose parameters genuinely need a body: long ID lists, rich filter objects, anything that keeps hitting URL limits or forcing you into POST-as-read. Plain GET remains the right verb for anything a user should be able to bookmark, share, or hit from a browser address bar.

Grab the full three-chapter demo, including the on-camera .http requests and the console client, from the HttpQuery repository.

Get the next post by email

One useful email when I publish something new. No spam, unsubscribe anytime.