Skip to main content

Command Palette

Search for a command to run...

HTTP Has a New Method: Let's Try QUERY

How RFC 10008’s safe, idempotent QUERY method compares with GET and POST, plus a hands-on Fastify demo using fetch, curl, and Postman.

Updated
11 min readView as Markdown
HTTP Has a New Method: Let's Try QUERY
QUERY /products/search HTTP/1.1
Host: localhost:3000
Content-Type: application/json

{
  "filters": {
    "minPrice": 50,
    "maxPrice": 300,
    "tags": ["workspace"],
    "inStock": true
  },
  "sort": { "field": "price", "direction": "asc" },
  "limit": 5
}

That first line is real! In June 2026, almost after a decade, the IETF published RFC 10008, “The HTTP QUERY Method”. HTTP now has a standard method for asking a resource a potentially complicated question without losing the fact that the operation is safe.

QUERY occupies a gap that API designers have worked around for years. GET is the natural choice for a read, but complex query input can be a poor fit for a URL. POST carries a body comfortably, but the method itself does not say, "This is only a read, and repeating it is safe." QUERY combines those missing properties: request content is expected, while the operation is explicitly safe and idempotent.

That sounds like "GET with a body," but the distinction is more useful than that phrase suggests. In this blog post, we will build a working QUERY endpoint with Fastify, call it from several clients, and then look at the harder question: what does it mean for the rest of the HTTP stack to support the method properly?

Complex reads have never fit neatly between GET and POST

Imagine a product search with a price range, several tags, stock status, sorting, pagination, selected fields, and perhaps a nested expression built by a visual filter editor. A GET endpoint starts pleasantly enough:

GET /products?minPrice=50&maxPrice=300&inStock=true&sort=price

As the query grows, so does the encoding work:

GET /products?filter[price][gte]=50&filter[price][lte]=300&filter[tags][]=workspace&filter[stock][available]=true&sort[0][field]=price&sort[0][direction]=asc&fields[]=id&fields[]=name&fields[]=price&limit=5

There is no single universal maximum URL length that makes this fail at a predictable point. A request can cross browsers, SDKs, gateways, proxies, servers, logging systems, and security products with different limits and assumptions. Long URLs are also awkward to generate and inspect, and query values are more likely to appear in histories and routine URL logs.

So, why not put a body on GET? HTTP does not assign generally applicable semantics to GET request content. Some clients can send it, some frameworks will parse it, and some intermediaries will ignore or reject it. Every participant needs out-of-band knowledge of what the body means. That is a fragile basis for an API.

The common escape hatch for this sort of situation is POST /products/search. It works almost everywhere, accepts a body, and may be the right compatibility choice. The compromise is semantic. POST is not inherently destructive, but HTTP also does not promise that a POST operation is safe or idempotent. An intermediary cannot infer from the method alone that retrying it will not create another order, enqueue another job, or repeat some other effect.

QUERY makes that promise explicit.

GET, POST, and QUERY make different promises

In HTTP, a safe method does not ask the target resource to change state. The server may still log the request, update metrics, allocate temporary resources, or do expensive work. Safety describes what the client requested, not an implementation, with zero side effects.

On the other hand, an idempotent method can be repeated without compounding the intended effect. However, it does not guarantee the same response bytes every time. Repeating a product query tomorrow may return different products because the catalog changed, but running the query again did not cause that change.

Property GET QUERY POST
Primary intent Retrieve a representation identified by the URI Run a query described by request content Ask the target to process request content
Safe Yes Yes Not guaranteed
Idempotent Yes Yes Not guaranteed
Request content No generally defined semantics Expected; meaning comes from the target and media type Expected; meaning comes from the target
Caching Mature URI-based support Cacheable, but the key must include content and relevant metadata Possible under HTTP rules, but not the usual reusable-query path
Best fit Compact, addressable, shareable reads Complex reads with controlled clients and infrastructure Actions, processing, or maximum ecosystem compatibility

Additionally, with QUERY, the request body contains the payload, and the Content-Type which tells the server how to interpret it. If the header is missing or does not match the body, RFC 10008 requires the server to reject the request. It also defines Accept-Query, a response header a resource can use to advertise the query types it understands.

Build a QUERY endpoint with Fastify

Fastify 5.11 added first-class RFC 10008 support, including the fastify.query() route shorthand and request-content checks.

Fastify 5 itself requires Node.js 20 or newer, but this demo needs a Node HTTP parser that recognizes QUERY. Node added working QUERY support in 22.2.0, so use Node 22.2 or newer here.

Create a project and install the current Fastify 5.x release:

mkdir query-demo
cd query-demo
npm init -y
npm install fastify@^5.12.0

Save this code as server.mjs:

import Fastify from 'fastify'

const fastify = Fastify({ logger: true })

const products = [
  { id: 1, name: 'Oak Desk', price: 280, tags: ['workspace', 'wood'], inStock: true },
  { id: 2, name: 'Monitor Stand', price: 75, tags: ['workspace', 'metal'], inStock: true },
  { id: 3, name: 'Desk Lamp', price: 45, tags: ['workspace', 'lighting'], inStock: true },
  { id: 4, name: 'Mechanical Keyboard', price: 140, tags: ['workspace', 'keyboard'], inStock: false },
  { id: 5, name: 'Felt Desk Mat', price: 55, tags: ['workspace', 'textile'], inStock: true },
  { id: 6, name: 'Wall Shelf', price: 190, tags: ['storage', 'wood'], inStock: true }
]

fastify.get('/', async () => {
  return 'QUERY demo is running. Open DevTools and try the fetch example.'
})

const bodySchema = {
  type: 'object',
  required: ['filters', 'sort', 'limit'],
  additionalProperties: false,
  properties: {
    filters: {
      type: 'object',
      additionalProperties: false,
      properties: {
        minPrice: { type: 'number', minimum: 0 },
        maxPrice: { type: 'number', minimum: 0 },
        tags: { type: 'array', items: { type: 'string' } },
        inStock: { type: 'boolean' }
      }
    },
    sort: {
      type: 'object',
      required: ['field', 'direction'],
      additionalProperties: false,
      properties: {
        field: { type: 'string', enum: ['price', 'name'] },
        direction: { type: 'string', enum: ['asc', 'desc'] }
      }
    },
    limit: { type: 'integer', minimum: 1, maximum: 50 }
  }
}

fastify.query('/products/search', {
  schema: { body: bodySchema }
}, async (request, reply) => {
  const { filters, sort, limit } = request.body
  const direction = sort.direction === 'asc' ? 1 : -1

  const matches = products
    .filter(product => filters.minPrice === undefined || product.price >= filters.minPrice)
    .filter(product => filters.maxPrice === undefined || product.price <= filters.maxPrice)
    .filter(product => filters.inStock === undefined || product.inStock === filters.inStock)
    .filter(product => filters.tags === undefined ||
      filters.tags.every(tag => product.tags.includes(tag)))
    .sort((left, right) => {
      const a = left[sort.field]
      const b = right[sort.field]
      return (typeof a === 'string' ? a.localeCompare(b) : a - b) * direction
    })
    .slice(0, limit)

  reply.header('Accept-Query', 'application/json')
  return { count: matches.length, products: matches }
})

await fastify.listen({ port: 3000 })

QUERY promises that the request will not change application state. Fastify does not enforce that promise; the handler must honor it. This example only filters the catalog, so it behaves as a safe query.

Start the server:

node server.mjs

Send the request three ways

The server is running, so we can send the same query from various development tools.

1. curl

curl’s --request option, shortened to -X, lets you choose a custom method string. -X QUERY selects the method, while --data-binary supplies the content:

curl -i -X QUERY http://localhost:3000/products/search \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "filters": {
      "minPrice": 50,
      "maxPrice": 300,
      "tags": ["workspace"],
      "inStock": true
    },
    "sort": { "field": "price", "direction": "asc" },
    "limit": 5
  }'

2. Browser fetch()

Browser fetch() accepts method tokens other than its small forbidden set, so QUERY can be sent programmatically.

Visit http://localhost:3000/, open DevTools, paste this into the Console, and press Enter:

const query = {
  filters: {
    minPrice: 50,
    maxPrice: 300,
    tags: ['workspace'],
    inStock: true
  },
  sort: { field: 'price', direction: 'asc' },
  limit: 5
}

const response = await fetch('/products/search', {
  method: 'QUERY',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(query)
})

console.log(await response.json())

Browser may initially block pasted code as a security precaution. After reviewing the snippet, manually type allow pasting and press Enter.

Once the query is processed successfully, the result appears in the Console. Navigate to the Network tab and look for a request named search; its Method column should show QUERY.

Entering http://localhost:3000/products/search navigates with GET; it does not infer QUERY and cannot attach the JSON body. In this demo, that navigation reaches no matching GET route and returns a not-found response.

3. Postman

Postman supports custom HTTP methods. So, you can configure the request like this:

Method: QUERY
URL: http://localhost:3000/products/search
Header: Content-Type: application/json
Body: raw JSON

And use the same query as the request body:

{
  "filters": {
    "minPrice": 50,
    "maxPrice": 300,
    "tags": ["workspace"],
    "inStock": true
  },
  "sort": { "field": "price", "direction": "asc" },
  "limit": 5
}

Cross-origin QUERY requests will preflight

If a frontend at http://localhost:5173 calls this API at http://localhost:3000, the browser sees a cross-origin request. Since QUERY is not one of the CORS-safelisted methods, the browser therefore sends an OPTIONS preflight without QUERY. Thus, it needs to be configured.

Install Fastify’s CORS plugin:

npm install @fastify/cors

Then import and register it before the routes:

import cors from '@fastify/cors'

await fastify.register(cors, {
  origin: 'http://localhost:5173',
  methods: ['GET', 'QUERY', 'OPTIONS'],
  allowedHeaders: ['Content-Type'],
  exposedHeaders: ['Accept-Query']
})

Verify the Access-Control-Allow-Methods:

curl -i -X OPTIONS http://localhost:3000/products/search \
  -H 'Origin: http://localhost:5173' \
  -H 'Access-Control-Request-Method: QUERY' \
  -H 'Access-Control-Request-Headers: Content-Type'

The response should allow the origin, the Content-Type header, and the QUERY method.

Accepting QUERY is not the same as supporting it

An HTTP method begins as a token in a request line or a :method pseudo-header. Many clients and servers let applications supply an arbitrary valid token. For example, older Fastify versions could be taught to parse the body with:

fastify.addHttpMethod('QUERY', { hasBody: true })

That can be enough to move bytes between a client and a handler. However, the official RFC support has more layers:

  1. The client must send the method and content correctly.

  2. The server must recognize QUERY, require content metadata, parse the body, and route the request.

  3. The handler must honor the safe and idempotent contract.

  4. Gateways, WAFs, load balancers, and proxies must forward the method and body instead of rejecting or rewriting them.

  5. Retry logic must recognize the method as idempotent and be able to replay its content.

  6. A cache must include the request content and relevant metadata in its key, not just the URI.

Current State

This is where adoption stands in August 2026: the method is registered, clients can often send it, and Fastify has first-class support, but the surrounding infrastructure is still catching up. For example, active implementation work in Envoy and nginx separates basic method recognition from harder questions such as retry semantics and body-aware caching.

Also, a successful local request proves your two endpoints can talk. It says nothing about an API gateway, production WAF, CDN, SDK generator, tracing system, or cache you have not tested.

It will be interesting to see how quickly QUERY moves from early experimentation to dependable, end-to-end adoption.

Should you use QUERY now?

Keep GET when the query is compact and its URI is useful. GET has unmatched support for links, bookmarks, shared URLs, ordinary caches, browser navigation, and crawlers. A new method is not an improvement when a familiar one already models the operation well.

Consider QUERY when the operation is genuinely safe and idempotent, the input is too structured or voluminous for a comfortable URI, and you control enough of the client-to-server path to test it. Internal APIs, experiments, and tightly managed systems are natural early candidates.

Keep POST as the pragmatic fallback when public clients, existing gateways, API description tools, generated SDKs, or organizational conventions cannot handle QUERY reliably. POST /search may communicate less through the method, but compatibility is a real design requirement. Documenting that the operation is read-only and defining retry behavior explicitly can be safer than deploying a precise method that part of the stack misunderstands.