Rails 8.2: The HTTP QUERY Method

Rails 8.2: The HTTP QUERY Method

RFC 10008, published in June 2026, defines the QUERY method as safe and idempotent like GET, and it carries its query in the request body like POST. Ruby on Rails merged support for QUERY on August 14, 2026, under the 8.2.0 milestone.

I set up a small application on edge Rails to check it working. The routing and the request object are ready, but the parts of the specification that make QUERY more than a POST with better manners are not quite there yet, since Rails parses only JSON bodies and does not enforce the content type rule RFC 10008 requires. In this article, we will check how the QUERY method works in Rails, how to route and test it, how Puma is handling it, and which pieces of the specification are still missing. Everything below reflects the state of things at the beginning of September 2026, and since Rails, Rack, and Puma all have the interesting parts sitting on unreleased branches, it is worth checking the versions yourself before trusting any of it.

What the QUERY method is

RFC 10008 opens a new window became a Proposed Standard in June 2026. It defines QUERY as a request method that is safe and idempotent with regard to the target resource, and that carries content in the request body.

That combination is the part neither GET nor POST can offer. It is worth noting that a GET with a body is not actually illegal, it is just that nothing useful is defined for it. RFC 9110 section 9.3.1 puts it plainly: a payload in a GET “has no defined semantics”, and sending one “might cause some implementations to reject the request”. A POST carries the body reliably, and it also announces to every cache and every client library that the request may have changed something on the server, so nothing retries it automatically. Its responses are cacheable in principle, but only with the cache control headers that section 9.3.3 requires, which is not something a typical search endpoint sets up.

Back in RFC 10008, a few details are worth knowing before you reach for it. Servers MUST fail the request if the Content-Type field is missing or is inconsistent with the request content (section 2). Responses are cacheable, but the cache key MUST incorporate the request content (section 2.7), which is a harder problem for a shared cache than hashing a URL. Section 3 defines an Accept-Query response header so a resource can advertise which query format media types it accepts, although Rails does not implement that part yet.

Those are the mechanics, and the specification is just as explicit about why you would want the query in the body to begin with. Size is the obvious half, since every proxy and CDN in front of you picks its own URL length limit. The other half is exposure, and it is worth being precise about: a URI is “more likely to be logged or otherwise processed by intermediaries than the request content”. This is not an encryption argument, because under TLS the path and query travel inside the same tunnel as the body. It is that a URI ends up in access logs, proxy logs, browser history, and Referer headers, and a request body usually ends up in none of them. In section 4 opens a new window , it follows the thought through: if a server mints a URI to represent the results of a QUERY, that URI SHOULD NOT contain sensitive portions of the original request content.

WebDAV defined SEARCH back in 2008 at RFC 5323 opens a new window , and ActionDispatch::Request has recognized it for years, in the same HTTP_METHODS list that QUERY now joins a few lines below it. And now QUERY has one advantage SEARCH never had: it is a general-purpose HTTP method rather than a WebDAV extension.

Routing a QUERY request

Rails 8.2 adds a query route helper that sits alongside get and post. It is a thin wrapper that delegates straight to match with via: :query, so you can write it either way. Here is the routes file from the small application I set up to test it:

# config/routes.rb
Rails.application.routes.draw do
  query "search", to: "search#index"
  match "filter", to: "search#filter", via: :query
end

bin/rails routes then reports the verb the same way it reports any other:

Prefix Verb  URI Pattern         Controller#Action
search QUERY /search(.:format)   search#index
filter QUERY /filter(.:format)   search#filter

These routes are scoped to the verb, which is worth spelling out because it is easy to assume otherwise when you are used to resources generating several verbs for one path. A GET to /search does not fall through to the QUERY action, it just does not match, and neither does a POST. Both come back as a 404 while the QUERY request returns a 200. The named helpers work as usual, so search_path is there alongside them.

The pull request opens a new window notes that the helper also works inside resources blocks with on: :collection and on: :member, but the resources shortcuts themselves were left out of scope, so there is no generated QUERY route yet.

What the request object gives you

Inside the controller, QUERY behaves like any other verb. request.query? does what you would expect, and request.request_method_symbol returns :query. More useful is request.safe_method?, which now returns true for QUERY alongside GET, HEAD, OPTIONS, and TRACE, with request.unsafe_method? as its inverse. If you have written a middleware opens a new window or an audit log that branches on whether a request is allowed to change state, that predicate is a better thing to call than a hand-maintained list of verbs.

The controller I tested does nothing but echo back what the request reports:

# app/controllers/search_controller.rb
class SearchController < ApplicationController
  def index
    render json: {
      method: request.request_method,
      method_symbol: request.request_method_symbol,
      query?: request.query?,
      safe_method?: request.safe_method?,
      filters: params[:filters]
    }
  end
end

Sending it a QUERY request with a JSON body gives back:

{
  "method": "QUERY",
  "method_symbol": "query",
  "query?": true,
  "safe_method?": true,
  "filters": { "status": "active" }
}

Notice where filters came from, it arrived in the request body rather than the query string, and it landed in params anyway. That is ActionDispatch::Http::Parameters#parse_formatted_parameters doing the work, picking a parser out of DEFAULT_PARSERS based on the request’s content type without ever looking at the verb, and #parameters then merging what comes back together with the query string and the path parameters.

That dependency on the content type cuts both ways. RFC 10008 says a server MUST fail the request when the Content-Type is missing or inconsistent with the content, and Rails does not do that, so on this point the implementation is not yet spec compliant. A QUERY request with no Content-Type at all comes back as a 200 with an empty params and the body quietly discarded. The only parser Rails registers by default is the JSON one, since form data is handled upstream by Rack, so anything else gets dropped the same way: an application/sql body never reaches params at all, which is worth knowing given that the RFC has exactly those structured query formats in mind. A body that contradicts its declared type does fail, but as a 400 from the parameter parser rather than as anything QUERY-specific.

Testing it

Integration tests get a query helper that mirrors get and post, so the test reads like any other request test:

# test/integration/query_method_test.rb
class QueryMethodTest < ActionDispatch::IntegrationTest
  test "routes a QUERY request and parses the JSON body" do
    query "/search", params: { filters: { status: "active" } }, as: :json

    assert_response :success
    body = JSON.parse(response.body)
    assert_equal "QUERY", body["method"]
    assert_equal true, body["query?"]
    assert_equal true, body["safe_method?"]
    assert_equal({ "status" => "active" }, body["filters"])
  end

  test "the same path does not answer GET or POST" do
    get "/search"
    assert_response :not_found
    post "/search"
    assert_response :not_found
  end
end

Controller tests do not get a dedicated helper, but process takes the method explicitly with process :index, method: "QUERY". One thing to watch there: a controller test written that way sends its params as application/x-www-form-urlencoded unless you say otherwise, so it will not exercise the JSON parsing path that your actual client uses.

QUERY and CSRF protection

Because QUERY is safe, Rails exempts it from forgery protection the same way it exempts GET and HEAD. The exemption is narrower than it first looks, though. As the comment in the source puts it, QUERY requests are exempt “but only when the request actually arrived with the QUERY method”, which is why the predicate checks two things that look like the same thing:

# actionpack/lib/action_controller/metal/request_forgery_protection.rb
def verified_request?
  request.get? || request.head? || verified_query_request? || !protect_against_forgery? ||
    (valid_request_origin? && verified_request_for_forgery_protection?)
end

def verified_query_request?
  request.query? && request.method == "QUERY"
end

The difference is that request_method reflects the method after any override has been applied, while request.method reads rack.methodoverride.original_method first and only falls back to REQUEST_METHOD, so it reports the method the request actually arrived with. The rationale for exempting genuine QUERY requests is that an HTML form cannot emit one, and that a cross-origin QUERY from JavaScript is always preflighted. Neither of those holds for a form POST carrying an override parameter.

Whether that guard does anything depends on which Rack you are running, which took me a failing test to work out. On Rack 3.2.7, the current release, Rack::MethodOverride::HTTP_METHODS does not list QUERY, so a form posting _method=query is ignored outright: the request stays a POST, request.query? is false, and it needs a token for the ordinary reason rather than the QUERY-specific one. On Rack main that constant does list QUERY, and ALLOWED_METHODS is still just POST, so the override goes through and the Rails predicate is the thing standing between a plain form submission and a skipped CSRF check.

An open pull request opens a new window proposes dropping QUERY from the _method form parameter while keeping it available through the X-HTTP-Method-Override header, on the grounds that a custom header always forces a CORS preflight and a form parameter does not.

Puma has to allow it first

Everything above works in tests, and tests run the Rack application directly. Rails reads REQUEST_METHOD out of the Rack env and trusts it, so as far as Rails is concerned the method is whatever the server handed over. That makes the server the real gatekeeper, and the current release of Puma does not let QUERY through.

Puma keeps an allowlist, which it introduced in Puma 6 opens a new window and made configurable after the WebDAV crowd pointed out what it broke. On Puma 8.0.2, Puma::Const::SUPPORTED_HTTP_METHODS holds HEAD, GET, POST, PUT, DELETE, OPTIONS, TRACE, and PATCH, and nothing else. Send a QUERY request to a default Puma and it never reaches your routes:

HTTP/1.1 501 Not Implemented

Puma caught this error: QUERY method is not supported (Puma::HttpParserError501)
.../puma-8.0.2/lib/puma/client.rb:305:in 'Puma::Client#parser_execute'

We can allow it with one line in config/puma.rb, using the supported_http_methods option Puma exposes for exactly this:

# config/puma.rb
supported_http_methods Puma::Const::SUPPORTED_HTTP_METHODS + ["QUERY"]

With that in place the same request reaches the controller and comes back with a 200. Passing supported_http_methods :any also works and turns the check off completely, which is worth avoiding if you can just name the methods you serve.

Treat that line as a stopgap rather than something to keep, because Puma has already fixed this upstream. On main, both SUPPORTED_HTTP_METHODS and IANA_HTTP_METHODS list QUERY, annotated with QUERY added based on https://www.rfc-editor.org/rfc/rfc10008.html, which means version 8.0.3 will accept QUERY with no configuration at all.

That fix is also a good illustration of how the pieces had to line up. In the Rails pull request, Sean Doyle raised the point that support needs coordinating across Rack, Puma, and nginx, and then wrote it: the Rack change opens a new window landed on July 12, 2026 and the Puma change opens a new window the day before. The part nobody can patch for you is the rest of the path. If you have a CDN, a load balancer, or an nginx in front of the application, each one has its own opinion about methods it does not recognize.

Conclusion

In this article we talked about the QUERY method from RFC 10008, the query route helper and its match via: :query equivalent, the request.query? and request.safe_method? predicates, the query integration test helper, and why Rails exempts genuine QUERY requests from CSRF protection but not the ones tunneled through a form POST.

The support was merged on August 14, 2026 and sits on main under the 8.2.0 milestone.

SEARCH got recognized and then went nowhere: Action Dispatch has accepted it for years and Puma lists it among the IANA methods, but it never made Puma’s default allowlist. QUERY has had Rack, Puma, and Rails all land support inside about five weeks, and Puma took it straight into the defaults.

Is your application several versions behind, with even the upgrade from Rails 8.0 to 8.1 opens a new window still ahead of you? Send us a message, we can help! opens a new window

Get the book