
HTTP QUERY Method (RFC 10008) Implemented in Python: Getting a Feel for the Differences from GET and POST
This page has been translated by machine translation. View original
Introduction
In June 2026, a new HTTP method QUERY was officially standardized as RFC 10008. This method takes the "best of both worlds" from GET and POST — I actually implemented a server in Python and got it running.
TL;DR

What is the QUERY Method?
When designing search APIs over HTTP, developers have long struggled with 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 have a body. Complex search conditions must be crammed into URL query strings, causing pain with URL length limits (effectively around 2048 characters) and representing nested structures
- POST can have a body, but carries the semantics of "an operation that modifies state." Proxies and caches treat it as "having side effects," making caching difficult and automatic retries unsafe
QUERY is a method that maintains GET's safety, idempotency, and cacheability while allowing a structured request body to be sent, just like POST.
What Are Safe, Idempotent, and Cacheable, and Why Do They Matter?
Here is a concrete explanation of each of the three properties from the table above.
Safe
This means the server's state does not change when a request is sent. Reading a page with GET is safe; deleting a record with DELETE is not.
Why it matters: Browsers, crawlers, and prefetch mechanisms will freely send requests for "safe methods." In 2005, Google Web Accelerator prefetched links, causing DELETE operations disguised as GET to fire, resulting in users' data being deleted. The "safe" declaration is an indispensable signal for automation across the entire infrastructure.

Idempotent
This means the result is the same whether the request is sent once or 100 times. GET, PUT, and DELETE are idempotent. POST is not (sending a payment twice may result in being charged twice).
Why it matters: Automatic retries during network failures. If a request times out, clients and proxies can automatically resend an idempotent method. Since resending POST is not safe, browsers show a confirmation dialog saying "Do you want to resubmit the form?"

Cacheable
This means the response can be stored and reused for identical requests.
Why it matters: Performance. CDNs cache GET responses at locations around the world. POST responses are generally not cached because caching mechanisms recognize that POST is a state-modifying operation, meaning the response could become stale immediately. QUERY is cacheable just like GET, but since the request body is also included in the cache key, different query bodies use different cache entries.

QUERY Is Not a Replacement for GET or POST
QUERY is not a replacement for existing methods — it fills a use case that previously had no appropriate method.
| Use Case | Appropriate Method | Reason |
|---|---|---|
| Retrieving a resource by URL | GET | Simple, universal, the URL itself is the resource identifier |
| Search with few parameters | GET | ?q=shoes&color=red level is fine in a URL |
| Creating, updating, or deleting resources | POST/PUT/DELETE | State-modifying operations |
| Search with complex structured queries | QUERY | Search conditions that exceed GET's URL limitations |
When to use GET: When the query fits in the URL. Simple filters, pagination, keyword search. The majority of today's search APIs are fine with GET.
When to use POST: When actually modifying state. Creating records, submitting forms, triggering actions.
When to use QUERY: When search conditions don't fit in URL parameters. Nested filters, geolocation queries, multiple array conditions, sending structured query languages. Before QUERY existed, POST was repurposed for this use, sacrificing cacheability.

Why Didn't It Exist Until Now?
The co-authors of RFC 10008 are James Snell of Cloudflare and Mike Bishop of Akamai. The fact that engineers from two major CDN companies wrote the specification suggests that QUERY support at the CDN level could be realized relatively quickly.
For many years, the POST /search pattern was effectively the standard for search APIs, but semantically this means "create a search resource," which diverges from the actual intent. The QUERY method fundamentally resolves this problem.
Prerequisites / Environment
- Python 3.12+
- Starlette 0.46+ (ASGI framework)
- uvicorn 0.34+
- httpx 0.28+ (client)
- uv (package manager)
The demo code is in the following repository:
Overview of the Demo
Using a product catalog search API as the subject, we execute the same search conditions with all three methods — GET, POST, and QUERY — and compare the differences.
Example search conditions:
{
"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"}
}
Meaning of each field:
| Field | Type | Description |
|---|---|---|
categories |
string[] | Target categories. Multiple values specified as an array (OR condition) |
price |
object | Price range. Nested range specification with min/max |
tags |
string[] | Product tags. Matches all values in the array (AND condition) |
min_rating |
number | Minimum rating (0–5) |
in_stock |
boolean | Filter to only in-stock products |
near |
object | Proximity search by location. Latitude, longitude, and radius specified as nested values |
sort |
object | Sort conditions. Target field and ascending/descending order specified as nested values |
Worth noting is that price, near, and sort are nested objects. Trying to express these in GET query strings requires flattening them as price_min=500&price_max=2000&near_lat=35.68&near_lng=139.76&near_radius=1.0, which loses the structure. As the number of fields grows, the URL gets longer and quickly hits the practical limit (around 2048 characters).

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",
]
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, we were able to handle this by passing a custom method name to the methods parameter of Route.
async def search_dispatcher(request: Request) -> JSONResponse:
"""Dispatch to handlers based on 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 key point is that Starlette's Route accepts any string in the methods list. The framework doesn't explicitly "support" QUERY — it just accepts unknown method names.
GET Handler: The Limits of Flat Query Strings
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 parameters like near_lat, near_lng, near_radius also needed
To express nested structures (price.min, near.lat), you need to define your own convention for flat parameter names (price_min, near_lat). This requires an implicit agreement between client and server, and makes OpenAPI schema representation cumbersome as well.
POST Handler: 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 treat POST as a "state-modifying operation" and don't cache the response
- Automatic retries are unsafe during network failures (sending the same POST twice may trigger side effects twice)
- The browser's back button asking "Do you want to resubmit the form?" is also an expression of the fact that POST is not safe
QUERY Handler: An RFC 10008-Compliant Implementation
async def search_via_query(request: Request) -> JSONResponse:
body = await request.body()
# RFC 10008 §3: Content-Type header is required
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: Respond to unsupported media types with 415 + Accept-Query header indicating supported types
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: QUERY responses are 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"',
},
)
Key error handling points defined by RFC 10008:
| Situation | Status Code | Description |
|---|---|---|
| No Content-Type header | 400 Bad Request | QUERY requires Content-Type |
| Unsupported media type | 415 Unsupported Media Type | Notify supported types via Accept-Query header |
| Unparseable body | 422 Unprocessable Content | Media type is correct but content is invalid |

Cache Implementation
The biggest advantage of QUERY is cacheability. Unlike GET, the cache key must include not just 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 states that caches may normalize "semantically insignificant differences" in the body. For example, in JSON, differences in key order or indentation can be ignored. However, normalization must not be performed if the client specifies the no-transform cache directive.
Verification
Sending a QUERY Request with curl
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"}
}'
Since curl can specify any HTTP method with -X QUERY, it works as-is.
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",...}]}
Second Response (Cache HIT)
Sending the same request again:
HTTP/1.1 200 OK
x-search-method: QUERY
x-cache: HIT
x-cache-key: QUERY:/products/search:7f73fb16e7395e7d
It hits on the same cache key. With POST, this caching behavior cannot be achieved per specification.
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 custom HTTP methods via the request() method
resp = client.request(
"QUERY",
"/products/search",
content=json.dumps(SEARCH_QUERY),
headers={"Content-Type": "application/json"},
)
print(resp.json())
Since httpx's client.request() accepts any HTTP method name as its first argument, QUERY requests can be sent without any special handling.
Visualizing GET's URL Length Problem
From the Python client execution results, checking the URL length for GET:
GET /products/search?... (flat query string)
→ URL length: 212 chars
Even with these simple search conditions, the URL is already 212 characters. In practice, search conditions can have 20–30 fields, quickly reaching the practical URL limit (around 2048 characters).
Verifying 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: ...
Via the Accept-Query header, clients can automatically learn "which media types this endpoint accepts for QUERY."
Important Specification Points of the QUERY Method
Accept-Query Header
The server can return Accept-Query in the response header to notify which media types are supported for QUERY.
Accept-Query: "application/json", application/sql;charset="UTF-8"
This becomes the foundation for content negotiation when supporting query languages other than JSON (SQL-like, JSONPath, etc.) in the future.
Redirect Behavior
QUERY redirects differ from POST:
| Status | Behavior |
|---|---|
| 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 behavior where methods changed to GET on 301/302, but QUERY defines this clearly.

CORS Impact
Since QUERY is not on the CORS safelist, preflight requests (OPTIONS) are required when sending from a browser.
Access-Control-Allow-Methods: GET, POST, QUERY, OPTIONS
This may impact performance for browser client APIs (additional round-trip communication occurs).

Relationship with GraphQL: Complementary, Not Competing
You might wonder: "Is the QUERY method trying to solve the same problem as GraphQL?" The short answer is they are complementary, not competing. The two operate at different layers.
| GraphQL | HTTP QUERY | |
|---|---|---|
| What it is | Query language + runtime | Transport method |
| Layer | Application layer (how to express queries) | Protocol layer (how to send queries) |
| What it defines | Schema, types, resolvers, field selection | Request safety, idempotency, cacheability |
GraphQL defines what to query, and HTTP QUERY defines how to send that query over HTTP.

GraphQL's Current Transport Problem
Today's GraphQL primarily sends queries using POST:
# Current common method for sending GraphQL
curl -X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-d '{"query": "{ products(category: \"laptops\") { name price } }"}'
Because of this, GraphQL inherits POST's problems as-is:
- CDNs don't cache responses (POST is treated as state-modifying)
- Cannot automatically retry during network failures
- No safety guarantees 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
By sending GraphQL read queries using the QUERY method, you get the benefits of both:
# GraphQL over HTTP QUERY — an ideal combination
curl -X QUERY https://api.example.com/graphql \
-H "Content-Type: application/graphql+json" \
-d '{"query": "{ products(category: \"laptops\") { name price } }"}'
CDNs can determine "this is safe, idempotent, and cacheable." GraphQL mutations (data modifications) remain as POST — since mutations actually modify state, POST semantics are correct for them.
The Real Competitive Axis
The market doesn't need to choose between GraphQL and QUERY. Competition exists at the level of API design philosophy:
| Comparison | Competing? |
|---|---|
| GraphQL vs REST | Yes — different API design approaches |
| HTTP QUERY vs repurposing POST for search | Yes — QUERY replaces the POST workaround |
| GraphQL vs HTTP QUERY | No — different layers, can be combined |

QUERY is actually good news for the GraphQL team. Without changing anything about GraphQL itself, the caching problem at the HTTP transport layer could be resolved.
Framework Support Status (as of June 2026)
| Framework | QUERY Support | Notes |
|---|---|---|
| Starlette | Possible as custom method | Works with methods=["QUERY"] |
| Express.js | app.query() not implemented |
Possible with app.all() + manual detection |
| FastAPI | Possible via Starlette | Native decorator not supported |
| Spring Boot | Custom annotation required | @RequestMapping(method="QUERY") |
| Ruby on Rails | Under discussion | Proposal posted on forum |
Production use is difficult until all of frameworks, reverse proxies, API gateways, CDNs, and WAFs support it. However, the fact that the spec co-authors are engineers from Cloudflare and Akamai suggests that CDN-level support could be realized early.
Summary
The HTTP QUERY method from RFC 10008 is the official answer to the long-standing workaround of "having to use POST for search APIs."
What QUERY resolves:
- Sending a structured request body, which was impossible with GET
- Restoring safety, idempotency, and cacheability that were lost with POST
- Explicit content negotiation via the
Accept-Queryheader
Current limitations:
- Almost no native framework support (many can handle it as a custom method)
- CDN, proxy, and WAF support is yet to come
- CORS preflight is required (for browser client APIs)
This is not the phase for immediate production deployment, but understanding the specification and being prepared has value. In particular, when designing APIs with complex search conditions, it's worth keeping in mind a "design that makes it easy to migrate to QUERY in the future."

