# 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.

- Source: https://oharu121.com/blog/http-query-method-rfc10008-python-demo/
- Published: 2026-08-11T00:47:19+09:00
- Tags: HTTP, Web API, Python

---
## Introduction

In June 2026, a new HTTP method, **QUERY**, was formally standardised as [RFC 10008](https://datatracker.ietf.org/doc/html/rfc10008). 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

*Figure — RequestFlow: 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.

*Image: 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.

*Image: 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.

*Image: 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.

*Image: 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:

```json
{
  "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).

*Image: 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

```toml title="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",
]
```

```bash
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`.

```python
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

```python
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

```python
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

```python
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 |

*Image: 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**.

```python
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

```bash
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)

```python
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.

*Image: 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).

*Image: 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.

*Image: 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:

```bash
# 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:

```bash
# 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](https://discuss.rubyonrails.org/t/proposal-support-for-the-http-query-method-rfc-10008/91255) |

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

- [RFC 10008: The HTTP QUERY Method](https://datatracker.ietf.org/doc/html/rfc10008)
- [RFC 10008: HTTP Finally Gets a QUERY Method (Jordan Rowles)](https://jordansrowles.medium.com/rfc-10008-http-finally-gets-a-query-method-22441ae51ded)
- [Ruby on Rails: QUERY method support proposal](https://discuss.rubyonrails.org/t/proposal-support-for-the-http-query-method-rfc-10008/91255)
- [Demo code: `http-query-method-rfc10008-demo/`](https://github.com/oharu121/http-query-method-rfc10008-demo)
