Build1 publisher3 min readPublished
A hardcoded eight-verb list in Django rejects QUERY before your handler runs
RFC 10008 made QUERY a Proposed Standard in June. An end-to-end test on a throwaway Frankfurt droplet found curl and FastAPI pass the new verb, while nginx's limit_except block rejects it silently.
The Engineer · Build desk

What happened
- RFC 10008 reached Proposed Standard in June, adding QUERY: a method that is safe and idempotent like GET but carries a request body like POST.
- The RFC warns that older proxies, frameworks and load balancer configs might not recognise the method, without naming any of them or saying what non-recognition looks like.
- Alex Georgiev tested it on a throwaway Frankfurt droplet, 2 vCPU and 4 GB on Ubuntu 24.04, running a FastAPI backend behind nginx, Caddy and Traefik plus a separate Django app.
- A Django class-based view with a query() handler returned 405 with Allow: OPTIONS and an empty body, because View.http_method_names is a hardcoded list checked before dispatch.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- decision Adopting QUERY is priced per endpoint rather than per service, so a team carrying a POST /search in every codebase is scheduling a sweep through route declarations, not flipping a switch.
- constraint Triage for the Django case begins in the wrong file, because the 405 describes what the class can do instead of the check that refused the request.
- exposure Where the edge config carries a method allowlist, an application team cannot ship a QUERY endpoint on its own; the decision belongs to whoever owns nginx.
- contradiction The RFC's warning covers load balancer configs, yet everything in this test path was a reverse proxy on a single host, so the layer flagged most vaguely is the one with no data behind it.
The failure order is the useful part. A QUERY request has to clear four decisions before a handler runs: curl has to emit the verb, the proxy has to pass it, the framework has to route it, and the view class has to dispatch it. Two of those held. The two that did not cost very different amounts to fix.
curl is the cheap layer. The `-X` flag takes the method as an opaque token, so `QUERY /search HTTP/1.1` went out with `Content-Type: application/json`, `Content-Length: 12` and the JSON body intact, with no flag and no patch [5]. FastAPI is nearly as cheap, and its interesting result is the one that worked: a route declared `methods=["QUERY"]` returned 200 and echoed the body [6], while the untouched `/docs` route returned 405 with `allow: GET, HEAD` [7]. Correct behaviour, and also the invoice. Starlette treats the method as a routing key, so QUERY does not fall through to an existing GET handler; ten routes that need to answer QUERY are ten edits, not a setting [8].
Django's 405 is the one that misdirects triage. The `Allow` header lists OPTIONS because OPTIONS is the only verb that class got for free, and no `get` or `post` was implemented either [9]. It names neither the method requested nor the handler that was written for it. The repair is a class attribute, `http_method_names = View.http_method_names + ["query"]`, after which the same handler and the same request return 200 with `received_method` set to QUERY [11]. Nine entries where the framework shipped eight [13].
The proxy result is asserted at a coarser grain than the framework ones. Alex Georgiev pins it to a directive, nginx's `limit_except` block [12], which tells you where to look but not what the client got back; the status line for that case is not in the writeup, and neither are the Caddy and Traefik results, though both proxies were in the rig [3]. That is enough to separate the two classes of problem. A directive that enumerates permitted methods by name cannot enumerate a verb that only reached Proposed Standard in June [1], which is my read of the mechanism rather than something the test measured. The Django line ships in your repository, gets a test, and appears in review. A `limit_except` block sits in a file older than the RFC, owned by whoever runs the edge, with no route table to grep. The RFC's warning about older proxies now has one worked example, which is one more than the RFC itself supplies [2].
For the nginx finding to transfer, your config has to carry a method allowlist at all. For the rest of it, the versions are the transfer condition: nginx 1.24.0, Django 6.1.1, FastAPI 0.141.1 on Starlette 1.6.0, curl 8.5.0 [4]. The whole rig was five processes on a single 4 GB droplet [3][15], so nothing in the tested path was a CDN or a managed load balancer [16], which is precisely the category the RFC names and does not explain [2].
The motivation has not changed since the first `POST /search` anyone wrote: GET cannot carry a real filter object, and nobody wants to argue with a URL length limit [14]. QUERY answers that once every hop agrees, and the hops are priced differently: one line in a view class, one argument per route, and a change request to whoever owns the edge.
What to watch
- Whether the Caddy and Traefik results, when published, show the same silent drop as nginx or a 405 a client can act on.
- Whether Django adds 'query' to View.http_method_names by default, which would remove the per-class line entirely.
- Whether CDN and managed load balancer vendors document their QUERY handling, since the RFC names load balancer configs and this test path contained neither.