The HTTP QUERY method, and who actually supports it
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:
| GET | POST | QUERY | |
|---|---|---|---|
| Request body has a defined meaning | No | Yes | Yes |
| Safe (doesn't change anything) | Yes | No | Yes |
| Idempotent (fine to retry) | Yes | No | Yes |
| Responses cacheable | Yes | Rarely | Yes, keyed on the body |
| Query ends up in URLs and logs | Yes | No | No |
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:
QUERY /products/search HTTP/1.1
Host: shop.example
Content-Type: application/json
Accept: application/json
{"category":"electronics","maxPrice":500,"inStock":true,"sort":"price"}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 gets400, and a well-formed query the server can't run gets422. - A new
Accept-Queryresponse header lists the query formats a resource accepts. It also tells clients that QUERY is supported at all:
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:
// 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);
}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:
| API | Response | What it tells a client |
|---|---|---|
| httpbingo.org | 200 with the method and body echoed back | It works. httpbingo is a Go rewrite of httpbin, and Go's net/http passes any method through. |
| httpbin.org | 405 with an Allow header | The right way to say no: the method isn't supported, and here's the list of ones that are. |
| jsonplaceholder.typicode.com | 404 | Misleading. The URL exists, but the router fell through, so a client would think it had the wrong path. |
| postman-echo.com | 400, 11 bytes of text/plain | Says the request was malformed, which it wasn't. |
| api.testingapis.com | 400, 11 bytes of text/plain | The same thing, and this one is ours. |
Here's our own API failing:
$ 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 RequestThis 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:
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:
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);QUERY: 200 {"method":"QUERY","filters":{"category":"electronics","maxPrice":500}}
GET: 405 Allow: QUERYNotice 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".
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:
"query" -> 400
"QUERY" -> 200 QUERY
"post" -> 200 POSTmethod: "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:
$ 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-typeQUERY 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: 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 productsWhether 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-Queryso clients can tell QUERY is supported before they try it. - Return a
Locationheader. 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
405with anAllowheader. 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.