engineering

RFC 10008: The HTTP QUERY Method, Implemented in Python

TL;DR: RFC 10008 adds a QUERY method to HTTP — it carries a request body like POST but is explicitly safe, idempotent, and cacheable like GET. If you've ever abused POST for a read-only search endpoint, this is the fix. Below is a dependency-free Python server and client that implement the full spec.


You've been there: a search filter that's too big for a URL, so you reach for POST even though nothing is being mutated. It works, but every cache in the path ignores the response, every proxy treats a retry as risky, and GraphQL has been shipping this as a "feature" for years.

The IETF just published RFC 10008, which standardizes QUERY — a new HTTP method that fixes this at the protocol level. Here's what the spec actually requires, and a server and client that implement it end to end.

Why GET and POST both fall short

GET is semantically correct for read-only queries — safe, idempotent, cacheable. But it can't carry a body, so every parameter has to fit in the URL. RFC 9110 sets a safe floor of 8,000 octets for a request target; some intermediaries cut off well before that. Multi-field filters, nested search expressions, long match strings — they all hit that ceiling fast.

POST has no size limit, which is why it became the default workaround. The cost is semantic: POST gives caches and proxies no guarantee the request is safe to store or retry. GraphQL is the clearest casualty — every operation travels over POST, including pure reads, so a read-only query gets none of the caching or retry behavior an equivalent GET would get for free.

How QUERY works

QUERY behaves like POST in transport and like GET in semantics. A few specifics from the spec matter for implementation:

  • Every QUERY request must carry a Content-Type; a missing or inconsistent value is a rejection (§2, §2.1).
  • QUERY is explicitly safe and idempotent (RFC 9110 §9.2.1, §9.2.2) — that's what licenses caching and automatic retry.
  • A successful response may carry Content-Location (a resource holding this specific result, §2.3) or Location (a resource that re-runs the same query on a future GET, §2.4).
  • Conditional request fields — If-Modified-Since, ETag — apply exactly as they do to GET (§2.6).
  • Servers can advertise accepted query formats via the new Accept-Query response field (§3).

A minimal request on the wire:

QUERY /contacts HTTP/1.1
Host: example.org
Content-Type: application/x-www-form-urlencoded
Accept: application/json

match="email=*@example.*"&limit=2

Working implementation

Most coverage of RFC 10008 stops at the wire format. Here's what actually running it looks like — stdlib Python, no dependencies.

Server: the only method that matters

http.server dispatches to do_<METHOD> on the handler class. Since QUERY is a valid Python identifier, do_QUERY just works:

def do_QUERY(self):
    length = int(self.headers.get("Content-Length", 0))
    body = self.rfile.read(length).decode()
    ctype = self.headers.get("Content-Type", "")

    if "application/x-www-form-urlencoded" not in ctype:
        return self.reply(415, {"error": "unsupported media type"},
                           [("Accept-Query", '"application/x-www-form-urlencoded"')])

    since = self.headers.get("If-Modified-Since")
    if since and parsedate_to_datetime(since).timestamp() >= store.modified:
        self.send_response(304)
        self.send_header("Last-Modified", formatdate(store.modified, usegmt=True))
        return self.end_headers()

    rows = store.filter(parse_qs(body))
    id_ = store.save(rows)
    self.reply(200, rows, [
        ("Content-Location", f"/contacts/stored-results/{id_}"),
        ("Last-Modified", formatdate(store.modified, usegmt=True)),
    ])

Three things happen here: Content-Type is validated (§2.1), the conditional If-Modified-Since check runs (§2.6), and the result is saved and returned with a Content-Location so the caller can fetch it later by GET (§2.3).

Warning: If-Modified-Since is an HTTP-date, which only has whole-second precision. If your stored timestamp still has a fractional second, the 304 check fails intermittently. Truncate at the source: self.modified = float(int(time.time())). It's the kind of bug that only shows up when you actually run the test.

Client

urllib.request.Request takes an explicit method argument, so no third-party library needed:

def query(params, headers=()):
    body = urllib.parse.urlencode(params).encode()
    req = urllib.request.Request(f"{BASE}/contacts", data=body, method="QUERY")
    req.add_header("Content-Type", "application/x-www-form-urlencoded")
    for k, v in headers:
        req.add_header(k, v)
    with urllib.request.urlopen(req) as r:
        return r.status, dict(r.headers), json.loads(r.read())

That's it. The method string is all that separates a QUERY from a POST.

Note: Full server + client source (including OPTIONS, HEAD, GET handlers and the test script) is in the GitHub repo.

Output

$ python3 server.py &
$ python3 client.py
Allow: GET, QUERY, OPTIONS, HEAD
Accept-Query: "application/x-www-form-urlencoded"
QUERY: 200 /contacts/stored-results/1 [{'surname': 'Smith', ...}, {'surname': 'Jones', ...}]
GET stored result: [{'surname': 'Smith', ...}, {'surname': 'Jones', ...}]
Conditional QUERY: 304
Bad QUERY: 415 "application/x-www-form-urlencoded"

The output covers the key spec requirements: a successful query, fetching the stored result by GET, a conditional re-query that short-circuits to 304, and a malformed Content-Type rejected with a hint about what would have worked.

What changes for GraphQL and REST APIs

GraphQL is the most immediate beneficiary. With QUERY standardized, query operations have a clear path off POST and onto a method that caches and retries correctly — no change to the query language, just the HTTP verb. Whether the ecosystem actually moves depends on clients and gateways shipping support.

For REST APIs, the impact lands on any search or filter endpoint currently on POST because the filter body doesn't fit in a URL. QUERY is the semantically correct replacement: it signals to every cache and proxy in the path that the operation is read-only, which POST never could.

Adoption is the open question. RFC 10008 is a finished standard, not a shipping feature — HTTP client libraries, server frameworks, and CDNs all need to add support before QUERY is usable without manual plumbing like what's in this post. Two of the three authors, James M. Snell and Mike Bishop, work at Cloudflare and Akamai — a reasonable signal that CDN-level support won't lag the spec by long.

Key takeaways

  • QUERY is safe + cacheable + has a body — a combination no existing HTTP method offered. That's the whole point.
  • GraphQL and REST search endpoints are the direct beneficiaries; both have been overloading POST for reads with no good alternative.
  • The Python implementation above is zero-dependency (http.server + urllib.request) and runnable in under a minute.
  • Watch the timestamp precision: HTTP-dates are whole-second; comparing against an unrounded timestamp silently breaks 304 responses.
  • The spec is done; the ecosystem isn't. Client libraries, frameworks, and CDNs still need to ship support before this is usable without custom handling.

Reference

RFC 10008: The HTTP QUERY Method — J. Reschke, J.M. Snell, M. Bishop, IETF, June 2026.

Thoughts? Leave a comment