TestingAPIs is still being built. If something breaks, email support@testingapis.com.

The HTTP QUERY method, and who actually supports it

Share

In June the IETF published RFC 10008, which defines a new HTTP method called QUERY. The last method to be standardised was PATCH, in 2010. QUERY works like a GET that carries a body: it's safe, idempotent and cacheable, and the query goes in the request body instead of the URL.

That sounds like something everyone should already support, so we tested it. We sent a QUERY request to five public test APIs, our own included, and got five different responses. Only one of them ran the query. We also tried Node, ASP.NET Core and Python, and all three handled it without trouble. The method is ready in application code. Proxies and servers along the way are another matter.

The problem QUERY solves

Say you have a product search with a dozen filters, a nested price range and a sort order. You have two options today, and both have drawbacks.

  • GET with everything in the query string. You have to encode nested filters into the URL somehow. Long URLs hit length limits in servers and proxies, and the whole query ends up in access logs, browser history and Refererheaders. Putting a body on a GET doesn't help: RFC 9110 says content in a GET request has no defined meaning, and plenty of proxies and clients drop it.
  • POST /search. This is what most APIs end up doing. It works, but POST tells every client and proxy that the request might change something. So nothing retries it automatically after a dropped connection, and no cache will store the result.

QUERY gives you a request body that clients, proxies and caches can still treat like a GET:

GETPOSTQUERY
Request body has a defined meaningNoYesYes
Safe (doesn't change anything)YesNoYes
Idempotent (fine to retry)YesNoYes
Responses cacheableYesRarelyYes, keyed on the body
Query ends up in URLs and logsYesNoNo

What a QUERY request looks like

It looks like a POST, except the method name tells everyone along the way that it's read-only:

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

{"category":"electronics","maxPrice":500,"inStock":true,"sort":"price"}
Response
HTTP/1.1 200 OK
Content-Type: application/json
Location: /products/search/q/7f3a9c
Cache-Control: max-age=60

[{"id":4,"name":"USB-C hub","price":39},{"id":11,"name":"Mechanical keyboard","price":89}]

The Locationheader is optional, and it's one of the more useful parts of the RFC. It gives a URL that runs the same query with a plain GET, so a client can send the body once and use an ordinary link after that. Content-Location means something slightly different: a URL that returns this particular result. A server can also reply 303 See Other with no results at all, which sends the client to GET them from the URL in Location.

What the RFC asks of servers

Most of this is about being strict with the body, because the body is the query:

  • Content-Typeis required. If it's missing, the server rejects the request with a 4xx, usually 400. The server must not guess the type from the body.
  • A format the resource doesn't accept for queries gets 415. A body that doesn't match its declared type gets 400, and a well-formed query the server can't run gets 422.
  • A new Accept-Query response header lists the query formats a resource accepts. It also tells clients that QUERY is supported at all:
Response
HTTP/1.1 415 Unsupported Media Type
Accept-Query: application/json, "application/jsonpath"
  • Caches can store QUERY responses, but the cache key has to include the request body. Two queries to the same URL with different filters are two different cache entries.
  • After a 301 or 302 redirect, the client sends the QUERY again to the new URL. Clients often turn a POST into a GET after those redirects, and the RFC rules that out for QUERY.

We sent QUERY to five public test APIs

Test APIs are built to echo whatever you send them, so you'd expect them to be the most forgiving servers around. Here's the script:

probe-query.mjs
// Send one QUERY request to each URL and report what came back.
const targets = [
  "https://httpbin.org/anything",
  "https://httpbingo.org/anything",
  "https://postman-echo.com/query",
  "https://jsonplaceholder.typicode.com/posts",
  "https://api.testingapis.com/api/methods/any",
];

for (const url of targets) {
  const res = await fetch(url, {
    method: "QUERY", // capitals: fetch only uppercases the six standard methods
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ category: "electronics", maxPrice: 500 }),
  });
  const type = (res.headers.get("content-type") ?? "-").split(";")[0];
  const allow = res.headers.get("allow") ?? "-";
  console.log(new URL(url).host.padEnd(29), res.status, type.padEnd(17), "allow:", allow);
}
Output (Node 24, 10 October 2026)
httpbin.org                   405 text/html         allow: HEAD, POST, TRACE, PUT, GET, OPTIONS, DELETE, PATCH
httpbingo.org                 200 application/json  allow: -
postman-echo.com              400 text/plain        allow: -
jsonplaceholder.typicode.com  404 application/json  allow: -
api.testingapis.com           400 text/plain        allow: -

That's five servers and five different answers:

APIResponseWhat it tells a client
httpbingo.org200 with the method and body echoed backIt works. httpbingo is a Go rewrite of httpbin, and Go's net/http passes any method through.
httpbin.org405 with an Allow headerThe right way to say no: the method isn't supported, and here's the list of ones that are.
jsonplaceholder.typicode.com404Misleading. The URL exists, but the router fell through, so a client would think it had the wrong path.
postman-echo.com400, 11 bytes of text/plainSays the request was malformed, which it wasn't.
api.testingapis.com400, 11 bytes of text/plainThe same thing, and this one is ours.

Here's our own API failing:

Terminal
$ curl -i -X QUERY https://api.testingapis.com/api/methods/any \
    -H "content-type: application/json" -d '{"q":"x"}'
HTTP/1.1 400 Bad Request
content-length: 11
content-type: text/plain
connection: close

Bad Request

This response never reached our application. Every response our ASP.NET Core app sends carries server: Kestrel and x-powered-by: TestingAPIs headers, and this one has neither. The API runs on Azure Container Apps, and the ingress proxy in front of it rejected the method before passing it on. Postman Echo sends back the same 11-byte body and an x-envoy-upstream-service-time header, so it looks like the same thing happens there, behind Envoy. We get the same 400 for a made-up method like FOO, so the proxy treats QUERY as an unknown method, not a bad request.

The lesson is about where the problem is. Your framework may handle QUERY fine while a load balancer, ingress or WAF in front of it turns the request away, and the error you get back gives no hint of that. So test QUERY through every hop a real client goes through, not just against localhost.

The frameworks already support it

Application code is in much better shape. We tried three servers on the machine we're writing this on, and each one handled QUERY with no extra setup.

Node.js 24. The built-in HTTP parser knows the method, and fetch sends it:

server.mjs
import http from "node:http";

console.log(http.METHODS.includes("QUERY")); // true on Node 24

http
  .createServer((req, res) => {
    if (req.method !== "QUERY") {
      res.writeHead(405, { allow: "QUERY" }).end();
      return;
    }
    let body = "";
    req.on("data", (chunk) => (body += chunk));
    req.on("end", () => {
      const filters = JSON.parse(body);
      res.writeHead(200, { "content-type": "application/json" });
      res.end(JSON.stringify({ method: req.method, filters }));
    });
  })
  .listen(3000);

ASP.NET Core 10. .NET 10 includes HttpMethods.Query on the server side and HttpMethod.Query for HttpClient:

Program.cs
using System.Net.Http.Json;

var app = WebApplication.Create(args);

app.MapMethods("/products/search", [HttpMethods.Query], async (HttpRequest req) =>
{
    var filters = await req.ReadFromJsonAsync<ProductFilter>();
    return Results.Ok(new { method = req.Method, filters });
});

app.Urls.Add("http://localhost:5000");
await app.StartAsync();

// Call it with HttpClient, then try a GET on the same route.
using var http = new HttpClient();
var msg = new HttpRequestMessage(HttpMethod.Query, "http://localhost:5000/products/search")
{
    Content = JsonContent.Create(new { category = "electronics", maxPrice = 500 })
};
var res = await http.SendAsync(msg);
Console.WriteLine($"QUERY: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");

var get = await http.GetAsync("http://localhost:5000/products/search");
Console.WriteLine($"GET:   {(int)get.StatusCode} Allow: {string.Join(", ", get.Content.Headers.Allow)}");

await app.StopAsync();

record ProductFilter(string Category, decimal MaxPrice);
Output (.NET 10)
QUERY: 200 {"method":"QUERY","filters":{"category":"electronics","maxPrice":500}}
GET:   405 Allow: QUERY

Notice the second line. A GET to a route that only handles QUERY gets a 405 with Allow: QUERYwithout you writing anything for it. That's the same polite refusal httpbin.org gave us.

Python. http.server passes each request to a do_<METHOD> handler, so adding QUERY takes one method. On the client side, urllib.request.Request accepts method="QUERY".

server.py
from http.server import BaseHTTPRequestHandler, HTTPServer
import json

class Handler(BaseHTTPRequestHandler):
    def do_QUERY(self):
        length = int(self.headers.get("content-length", 0))
        filters = json.loads(self.rfile.read(length))
        body = json.dumps({"method": self.command, "filters": filters}).encode()
        self.send_response(200)
        self.send_header("content-type", "application/json")
        self.end_headers()
        self.wfile.write(body)

HTTPServer(("127.0.0.1", 8000), Handler).serve_forever()

Spell it in capitals

HTTP method names are case-sensitive. The fetch standard uppercases six of them for you: DELETE, GET, HEAD, OPTIONS, POST and PUT. Anything else goes out exactly as you typed it. Here's what a Node server returned for three spellings:

Output (Node 24)
"query" -> 400
"QUERY" -> 200 QUERY
"post" -> 200 POST

method: "query" goes out as lowercase query, and the server answers 400. PATCH has had the same trap for years, and Node prints a warning when you write "patch". It doesn't warn for "query". If a QUERY request fails and you can't see why, check the capitals.

Browsers send a preflight first

QUERY isn't on the short list of methods that browsers send cross-origin without asking first. So before a cross-origin QUERY, the browser sends an OPTIONS preflight, and the server has to name QUERY in Access-Control-Allow-Methods. httpbingo was the one API that ran our query, and here's what it says to a browser:

Terminal
$ curl -i -X OPTIONS https://httpbingo.org/anything \
    -H "Origin: https://example.com" \
    -H "Access-Control-Request-Method: QUERY" \
    -H "Access-Control-Request-Headers: content-type"
HTTP/1.1 200 OK
access-control-allow-origin: https://example.com
access-control-allow-methods: GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS
access-control-allow-headers: content-type

QUERY isn't in that list, so the same request that worked from Node would fail in a browser with a CORS error. If your CORS settings list the allowed methods by name, add QUERY there too.

OpenAPI 3.2 can describe it

OpenAPI 3.2, released in September 2025, added a query operation to path items, next to get and post:

openapi.yaml
openapi: 3.2.0
info:
  title: Shop
  version: 1.0.0
paths:
  /products/search:
    query:
      summary: Search products with filters in the body
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProductFilter"
      responses:
        "200":
          description: Matching products

Whether your doc generator, client generator or API gateway reads that field yet is another question. Check before you rely on it, the same as with the network.

Should you use QUERY yet?

For traffic between your own services, where you control every hop, yes. Test the full path once and you get retries and caching that POST /search can't give you.

For a public API, we'd add it without taking anything away:

  • Keep POST /searchworking and add QUERY on the same resource. Clients and networks that can't send QUERY still have a way in.
  • Send Accept-Query so clients can tell QUERY is supported before they try it.
  • Return a Location header. Once a client has run a query, it can repeat it with a GET, which works through every proxy there is.
  • On routes that don't support QUERY, answer 405 with an Allowheader. Don't answer 400 or 404, which suggest a problem with the client's request.

If you're checking how your client handles a server that refuses a method, our HTTP methods lesson covers 405 and Allow, and the error responses guide shows how to get 4xx and 5xx responses from our API on demand.

It took PATCH years to work reliably across clients, proxies and frameworks, and QUERY will probably take a while too. The difference this time is that application code is ready on day one. Proxies are where it's still failing.