Tags
The IETF logo, a yellow signal line zigzagging over grey diamonds above the IETF wordmark in black on a white card

The HTTP QUERY method (RFC 10008) implemented in Python — how it differs from GET and POST

Implementing RFC 10008 HTTP QUERY in Python with Starlette: safe, idempotent, cacheable requests with a structured body, and how it complements GraphQL.

On this page

Introduction

In June 2026, a new HTTP method, QUERY, was formally standardised as RFC 10008. This method takes the best of GET and POST, and I actually implemented a server in Python and ran it.

https://github.com/oharu121/http-query-method-rfc10008-demo

TL;DR

GET / POST / QUERY — request flow and cache behaviourThree rows, each following one method from client through proxy to server. GET sends the URL only, is cacheable on the URI, and is safe and idempotent, but the URL is limited to about two thousand characters and cannot nest. POST sends a body, is never cached, is forwarded every time, and is unsafe to retry, and its semantics claim it changes state when it does not. QUERY sends a body, is cacheable on a hash of the URI and body, and is safe and idempotent: the safety of GET with the structured body of POST.GET / POST / QUERY — request flow and cache behaviourClientProxy / CDNServerGETGET /search?cat=laptops&…URL onlyCache HIT?cacheable on the URIon a missSafe + idempotentauto-retry is fineURL limit around 2,048 chars, and no nestingPOSTPOST /searchJSON bodywith bodyCache SKIPnot cacheable, treated as unsafeevery timeUnsaferetrying is riskySemantics say it changes state, which is not what is happeningQUERYQUERY /searchJSON bodywith bodyCache HIT?keyed on URI + body hashon a missSafe + idempotentauto-retry is fineGET's safety + POST's structured body = QUERY (RFC 10008)Legendcacheablenot cacheablecacheable with a body
Only QUERY gets both: a structured body and a cacheable, safe request.

What the QUERY method is

Designing search APIs over HTTP, developers have long carried this dilemma.

Property GET POST QUERY
Request body None Yes Yes
Safe Yes No Yes
Idempotent Yes No Yes
Cacheable Yes Limited Yes
  • GET is safe, idempotent, and cacheable but cannot carry a body. Complex search conditions must be crammed into the URL query string, and you struggle with the URL length limit (about 2,048 characters in practice) and expressing nested structures
  • POST can carry a body but has the semantics of “an operation that changes state”. Because proxies and caches judge it to “have side effects”, caching is hard to apply and automatic retries are not safe

QUERY is a method that maintains GET’s safety, idempotency, and cacheability while being able to send a structured request body like POST.

What safe, idempotent, and cacheable are, and why they matter

Here is each of the three properties from the table above, concretely.

Safe

Means that sending the request does not change the server’s state. Reading a page with GET is safe; deleting a record with DELETE is not.

Why it matters: browsers, crawlers, and prefetch mechanisms freely send requests as long as the method is “safe”. In 2005, when Google Web Accelerator prefetched links, DELETE operations disguised as GET fired and users’ data was deleted. The declaration of “safe” is an indispensable signal for automation across the whole infrastructure.

Comparison of a safe GET /page against an unsafe DELETE /users/123, with browsers, crawlers, and prefetchers freely sending requests that do not change server state

Idempotent

Means that sending the same request once or a hundred times gives the same result. GET, PUT, and DELETE are idempotent. POST is not (send a payment twice and you may be charged twice).

Why it matters: automatic retry on network failure. If a request times out, a client or proxy can automatically resend an idempotent method. Because resending POST is not safe, the browser shows a “resend the form?” confirmation dialog.

Comparison of idempotent GET, PUT, and DELETE against POST /payments creating a new payment on every send, with automatic retry after a network timeout

Cacheable

Means the response can be stored and reused for an identical request.

Why it matters: performance. A CDN caches GET responses at points of presence worldwide. POST responses are generally not cached, because the caching mechanism recognises POST as a state-changing operation and the response may become stale immediately. QUERY is cacheable like GET, but because it includes the request body in the cache key, different query bodies use different cache entries.

Comparison of cacheable GET, non-cacheable POST, and cacheable QUERY, where different request bodies map to different cache entries at a CDN

QUERY is not a replacement for GET or POST

QUERY is not a replacement for existing methods; it fills use cases that had no appropriate method until now.

Use case Appropriate method Reason
Fetch a resource by URL GET Simple, universal, the URL itself is the resource identifier
Search with a few parameters GET ?q=shoes&color=red fits fine in the URL
Create/update/delete a resource POST/PUT/DELETE State-changing operations
Search with a complex structured query QUERY Search conditions exceeding GET’s URL limits

When to use GET: when the query fits in the URL. Simple filters, pagination, keyword search. Most search APIs today are fine with GET.

When to use POST: when you actually change state. Creating records, submitting forms, triggering actions.

When to use QUERY: when the search conditions do not fit in URL parameters. Nested filters, geolocation queries, multiple array conditions, sending a structured query language. Before QUERY existed, this use was served by repurposing POST and sacrificing cacheability.

Decision guide for GET, POST, and QUERY, with an example request and typical use cases for each, and a note that this use was previously served by repurposing POST

Why it did not exist until now

The co-authors of RFC 10008 are Cloudflare’s James Snell and Akamai’s Mike Bishop. That engineers from the two big CDN companies wrote the spec suggests QUERY support at the CDN level may materialise relatively early.

For years, the “POST /search” pattern was the de facto standard for search APIs, but semantically this means “create a search resource”, diverging from reality. The QUERY method fundamentally solves this problem.

Prerequisites and environment

  • Python 3.12+
  • Starlette 0.46+ (ASGI framework)
  • uvicorn 0.34+
  • httpx 0.28+ (client)
  • uv (package manager)

The demo code is in this repository:

https://github.com/oharu121/http-query-method-rfc10008-demo

Overview of the demo

Using a product-catalog search API, I run the same search condition with the three methods GET, POST, and QUERY and compare the differences.

An example search condition:

{
"categories": ["laptops", "phones"],
"price": {"min": 500, "max": 2000},
"tags": ["pro"],
"min_rating": 4.5,
"in_stock": true,
"near": {"lat": 35.68, "lng": 139.76, "radius_deg": 1.0},
"sort": {"field": "price", "order": "desc"}
}

The meaning of each field:

Field Type Description
categories string[] Target categories. Multiple specified as an array (OR condition)
price object Price range. Nested range via min/max
tags string[] Product tags. Match all in the array (AND condition)
min_rating number Minimum rating (0–5)
in_stock boolean Narrow to in-stock products only
near object Nearby search by geolocation. Latitude, longitude, and radius specified nested
sort object Sort condition. Target field and ascending/descending specified nested

Note that price, near, and sort are nested objects. Trying to express these in a GET query string requires flattening them like price_min=500&price_max=2000&near_lat=35.68&near_lng=139.76&near_radius=1.0, and the structure is lost. The more fields, the longer the URL, and it quickly reaches the practical limit (about 2,048 characters).

A nested QUERY JSON body shown beside the same condition flattened into GET parameters that lose the field relationships, and a URL running past the practical 2,048-character limit

Server implementation

Project setup

pyproject.toml
[project]
name = "http-query-demo"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"starlette>=0.46",
"uvicorn>=0.34",
"httpx>=0.28",
]
Terminal window
uv sync
uv run uvicorn server:app --reload

Routing the QUERY method

As of June 2026, most web frameworks do not natively support the QUERY method. In Starlette, I handled it by passing a custom method name to the methods parameter of Route.

async def search_dispatcher(request: Request) -> JSONResponse:
"""Dispatch to a handler according to the HTTP method"""
match request.method:
case "GET":
return await search_via_get(request)
case "POST":
return await search_via_post(request)
case "QUERY":
return await search_via_query(request)
case "OPTIONS":
return await product_search_options(request)
case _:
return JSONResponse(
{"error": f"Method {request.method} not allowed"},
status_code=405,
headers={"Allow": "GET, POST, QUERY, OPTIONS"},
)
routes = [
Route(
"/products/search",
search_dispatcher,
methods=["GET", "POST", "QUERY", "OPTIONS"],
),
]
app = Starlette(routes=routes)

The point is that Starlette’s Route accepts any string in the methods list. It is not that the framework explicitly supports “QUERY”; it is that it accepted an unknown method name.

The GET handler: the limit of a flat query string

async def search_via_get(request: Request) -> JSONResponse:
params = request.query_params
query: dict[str, Any] = {}
if cats := params.get("categories"):
query["categories"] = cats.split(",")
if price_min := params.get("price_min"):
query.setdefault("price", {})["min"] = int(price_min)
if price_max := params.get("price_max"):
query.setdefault("price", {})["max"] = int(price_max)
# ... individual params like near_lat, near_lng, near_radius are needed

To express nested structures (price.min, near.lat), you have to define a flat parameter-name convention (price_min, near_lat) yourself. This requires an implicit agreement between client and server, and expressing it in an OpenAPI schema becomes cumbersome.

The POST handler: it works, but the semantics are wrong

async def search_via_post(request: Request) -> JSONResponse:
body = await request.body()
content_type = request.headers.get("content-type", "")
if "json" not in content_type:
return JSONResponse(
{"error": "Content-Type must be application/json"},
status_code=415,
)
query = json.loads(body)
data = search_products(query)
return JSONResponse(data)

The code is simple, but the problem is HTTP semantics.

  • Proxies and CDNs see POST as “an operation with a state change” and do not cache the response
  • Automatic retry on network failure is not safe (sending the same POST twice may cause the side effect twice)
  • The browser’s back button asking “resend the form?” is also an expression of POST not being safe

The QUERY handler: an RFC 10008-compliant implementation

async def search_via_query(request: Request) -> JSONResponse:
body = await request.body()
# RFC 10008 §3: a Content-Type header is mandatory
content_type = request.headers.get("content-type", "")
if not content_type:
return JSONResponse(
{"error": "QUERY requests MUST include a Content-Type header (RFC 10008 §3)"},
status_code=400,
)
# RFC 10008 §3: for an unsupported media type, respond 415 + notify supported types with Accept-Query
if "json" not in content_type:
return JSONResponse(
{"error": f"Unsupported media type: {content_type}"},
status_code=415,
headers={"Accept-Query": '"application/json"'},
)
# RFC 10008 §4: a QUERY response is cacheable
# include a hash of the request body in the cache key
cache_key = _cache_key("QUERY", request.url.path, body)
if cached := _get_cached(cache_key):
return JSONResponse(cached, headers={"X-Cache": "HIT"})
try:
query = json.loads(body)
except json.JSONDecodeError as e:
# RFC 10008 §3: syntactically correct but semantically unprocessable → 422
return JSONResponse(
{"error": f"Unprocessable query content: {e}"},
status_code=422,
)
data = search_products(query)
_set_cache(cache_key, data)
return JSONResponse(
data,
headers={
"X-Cache": "MISS",
"Accept-Query": '"application/json"',
},
)

The error-handling points RFC 10008 defines:

Situation Status code Description
No Content-Type header 400 Bad Request QUERY requires a Content-Type
Unsupported media type 415 Unsupported Media Type Notify supported types with the Accept-Query header
Unparseable body 422 Unprocessable Content The media type is correct but the content is invalid

Table of QUERY request failures returning 400 Bad Request, 415 Unsupported Media Type, and 422 Unprocessable Content, with an example request and response for each

Implementing the cache

QUERY’s biggest advantage is cacheability. Unlike GET, the cache key must include not only the URI but also the request body.

def _cache_key(method: str, path: str, body: bytes) -> str:
body_hash = hashlib.sha256(body).hexdigest()[:16]
return f"{method}:{path}:{body_hash}"

RFC 10008 §4 allows a cache to normalise “semantically insignificant differences” in the body. For JSON, for example, differences in key order or indentation can be ignored. However, if the client specifies the no-transform cache directive, normalisation must not be performed.

Verifying it works

Send a QUERY request with curl

Terminal window
curl -s -D - -X QUERY "http://localhost:8000/products/search" \
-H "Content-Type: application/json" \
-d '{
"categories": ["laptops", "phones"],
"price": {"min": 500, "max": 2000},
"tags": ["pro"],
"min_rating": 4.5,
"in_stock": true,
"near": {"lat": 35.68, "lng": 139.76, "radius_deg": 1.0},
"sort": {"field": "price", "order": "desc"}
}'

Because curl can specify any HTTP method with -X QUERY, it works as-is.

The first response (cache MISS)

HTTP/1.1 200 OK
x-search-method: QUERY
x-cache: MISS
x-cache-key: QUERY:/products/search:7f73fb16e7395e7d
accept-query: "application/json"
x-note: Safe + idempotent + cacheable + structured body (RFC 10008)
{"total":1,"offset":0,"limit":10,"results":[{"id":4,"name":"iPhone 16 Pro",...}]}

The second response (cache HIT)

Resending the same request:

HTTP/1.1 200 OK
x-search-method: QUERY
x-cache: HIT
x-cache-key: QUERY:/products/search:7f73fb16e7395e7d

It hits with the same cache key. This caching behaviour cannot, by spec, be achieved with POST.

Python client (httpx)

import httpx
import json
SEARCH_QUERY = {
"categories": ["laptops", "phones"],
"price": {"min": 500, "max": 2000},
"tags": ["pro"],
}
with httpx.Client(base_url="http://localhost:8000") as client:
# httpx supports a custom HTTP method via the request() method
resp = client.request(
"QUERY",
"/products/search",
content=json.dumps(SEARCH_QUERY),
headers={"Content-Type": "application/json"},
)
print(resp.json())

Because httpx’s client.request() accepts any HTTP method name as its first argument, it can send a QUERY request with no special handling.

Visualising GET’s URL-length problem

From the Python client’s run, check GET’s URL length:

GET /products/search?... (flat query string)
→ URL length: 212 chars

Even with this simple search condition, 212 characters. In practice search conditions can reach 20–30 items, quickly hitting the URL’s practical limit (about 2,048 characters).

Checking error handling

No Content-Type → 400: QUERY requests MUST include a Content-Type header (RFC 10008 §3)
Wrong Content-Type → 415: Unsupported media type: text/plain
Accept-Query header: "application/json"
Malformed JSON → 422: Unprocessable query content: ...

Thanks to the Accept-Query header, the client can automatically learn “which media types of QUERY this endpoint accepts”.

Important spec points of the QUERY method

The Accept-Query header

The server can return Accept-Query in the response header to notify the media types it supports for QUERY.

Accept-Query: "application/json", application/sql;charset="UTF-8"

This becomes the content-negotiation basis for supporting query languages other than JSON (SQL-like, JSONPath, etc.) in the future.

Redirect behaviour

QUERY’s redirects differ from POST’s:

Status Behaviour
301/308 (permanent) Resend QUERY to the new URI
302/307 (temporary) Resend QUERY to the new URI
303 (See Other) Send GET to the new URI

With POST there was ambiguous behaviour where the method changed to GET on 301/302, but with QUERY it is clearly defined.

Table comparing QUERY’s defined redirect behaviour on 301/308, 302/307, and 303 against POST’s historically ambiguous behaviour

The CORS impact

Because QUERY is not on the CORS safelist, sending from a browser requires a preflight request (OPTIONS).

Access-Control-Allow-Methods: GET, POST, QUERY, OPTIONS

This may affect performance in browser clients (an extra round trip occurs).

Four-step exchange in which a browser sends an OPTIONS preflight, receives 204 No Content, then sends the actual QUERY request and gets 200 OK

The relationship with GraphQL: complementary, not competing

You might wonder, “is the QUERY method trying to solve the same problem as GraphQL?” In short, they are complementary, not competing. The two operate at different layers.

GraphQL HTTP QUERY
What it is A query language + runtime A transport method
Layer Application layer (how to express a query) Protocol layer (how to send a query)
What it defines Schema, types, resolvers, field selection A request’s safety, idempotency, cacheability

GraphQL defines what to query, and HTTP QUERY defines how to send that query over HTTP.

Table contrasting GraphQL as an application-layer query language with HTTP QUERY as a protocol-layer transport, and a flow sending a GraphQL query over QUERY /graphql

GraphQL’s current transport problem

GraphQL today mainly sends queries with POST:

Terminal window
# the common way GraphQL is sent today
curl -X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-d '{"query": "{ products(category: \"laptops\") { name price } }"}'

So GraphQL inherits POST’s problems directly:

  • CDNs do not cache the response (POST is seen as a state change)
  • No automatic retry on network failure
  • No safety guarantee at the HTTP layer

Some GraphQL implementations also use GET (putting the query in the URL), but complex GraphQL queries quickly hit the URL-length limit.

QUERY can improve GraphQL’s transport

Send GraphQL read queries with the QUERY method and you get both benefits:

Terminal window
# GraphQL over HTTP QUERY — the ideal combination
curl -X QUERY https://api.example.com/graphql \
-H "Content-Type: application/graphql+json" \
-d '{"query": "{ products(category: \"laptops\") { name price } }"}'

A CDN can judge “this is safe, idempotent, and cacheable”. GraphQL mutations (data changes) stay POST — mutations actually change state, so POST’s semantics are correct.

The real axis of competition

The market does not need to choose between GraphQL and QUERY. What competes is at the level of API design philosophy:

Comparison Competing?
GraphQL vs REST Yes: a difference in API design approach
HTTP QUERY vs repurposing POST for search Yes: QUERY replaces the POST workaround
GraphQL vs HTTP QUERY No: different layers, combinable

Rather, QUERY is good news for GraphQL teams. Because the HTTP-transport-layer caching problem may be solved without changing GraphQL itself.

The same complementary relationship applies to other protocols that send requests with POST, such as JSON-RPC and gRPC-Web.

Framework support (as of June 2026)

Framework QUERY support Notes
Starlette Possible as a custom method Works with methods=["QUERY"]
Express.js app.query() not implemented Handled with app.all() + manual dispatch
FastAPI Possible via Starlette No native decorator
Spring Boot Needs a custom annotation @RequestMapping(method="QUERY")
Ruby on Rails Under discussion A proposal is in the forum

Unless frameworks, reverse proxies, API gateways, CDNs, and WAFs all support it, production use is difficult. That said, the spec’s co-authors being Cloudflare and Akamai engineers suggests CDN-level support may arrive early.

Summary

The HTTP QUERY method of RFC 10008 is the formal answer to the long-standing workaround “you have to use POST for search APIs”.

What QUERY solves:

  • Sending a structured request body, which was impossible with GET
  • Recovering the safety, idempotency, and cacheability lost with POST
  • Explicit content negotiation via the Accept-Query header

Constraints at present:

  • Native framework support is nearly nonexistent (though many can handle it as a custom method)
  • CDN, proxy, and WAF support is still to come
  • CORS preflight is required (for browser-client APIs)

It is not the phase to put into production right now, but it is worth understanding the spec and preparing. In particular, when designing an API with complex search conditions, it is worth keeping “a design easy to migrate to QUERY in the future” in mind.

References

Share this article