HTTP QUERY Method (RFC 10008) Implemented in Python: Getting a Feel for the Differences from GET and POST

HTTP QUERY Method (RFC 10008) Implemented in Python: Getting a Feel for the Differences from GET and POST

In June 2026, I implemented the standardized HTTP QUERY method (RFC 10008) in Python and ran comparisons with GET and POST to highlight the differences. This article explains the specifications and key implementation points of this new method, which supports a request body while being safe, idempotent, and cacheable.
2026.06.26

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.

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

TL;DR

http-query-method-rfc10008-python-demo-method-comparison

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.

e02de4d5-cc9c-4eda-972b-d47d4189fdba

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?"

02590137-784d-452f-8496-ff82977d5411

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.

a849dc46-dbda-45a6-9a07-860ae1129f03

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.

2f6a74f0-5fe0-494b-8dcf-14f6a8bedd99

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:

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

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

05dac2b2-fcc1-4b93-9de5-10f2f018a938

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

4d9e4b71-eeb6-4b71-a4b6-90d380bf12e0

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.

68105e07-7cd9-4c75-bf11-9410963cb14a

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

5b1bf509-8a2d-4a7b-927d-f397a33a7462

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.

991236dc-8b87-4c81-b0b2-63f2f34b4d3d

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

98a8aa01-7dc1-4732-a99e-27d430f547da

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-Query header

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

Share this article

Related articles