Skip to content
All posts

Deep Dive

Why HTTP Finally Got a QUERY Method (After 16 Years)

How the new HTTP QUERY method (RFC 10008) sends a request body for read-only queries: a safe, idempotent, cacheable middle ground between GET and POST, with Node.js examples.

2026-07-0515 min read
HTTPWebAPI DesignBackend

You're building a search API.

At first, GET works perfectly.

Then the filters grow, and your URL balloons to thousands of characters.

So you switch to POST, even though you're only reading data.

That's the exact problem the new HTTP QUERY method solves.


What you will learn

By the end of this post you will understand:

  • What safe and idempotent mean, with everyday analogies.
  • Why GET and POST both fall short for real searches.
  • What the new QUERY method (RFC 10008) is and how it helps.
  • A side-by-side Node.js example of a GET server and a QUERY server.
  • Why QUERY being cacheable is the whole point.
  • When to use GET, QUERY, or POST in real life.

If you remember one sentence, remember this:

QUERY is a read-only request that carries a body. It behaves like GET (safe and cacheable) but takes its input from the body like POST.

Before we start: QUERY is brand new. Most frameworks, proxies, and browsers are still adding support for it. It's great for services you control today, and it will spread across the rest of the web over the next few years.


1. A quick refresher: safe and idempotent

Two words describe every HTTP method, and QUERY depends on both of them. So let's get them straight before anything else.

Safe

A safe method only reads. It never changes anything on the server.

Think of reading a noticeboard. You read what's pinned and walk away. The board is exactly as you found it.

GET is safe. QUERY is too. A safe request can be repeated, cached, or pre-loaded with zero risk.

Idempotent

Doing it once or ten times leaves the server in the same state.

Think of pressing the call button for a lift. Press it once or jab it five times, the lift still comes exactly once.

This is the part that matters in practice: it makes retries safe. If a request times out, the client can just send it again. No double-charge, no duplicate row.

Here is where the common methods sit:

MethodSafe?Idempotent?Has a body?
GETyesyesno defined meaning
POSTnonoyes
PUTnoyesyes
PATCHnonoyes
DELETEnoyesrarely
QUERYyesyesyes

Look at the top row for a second. Before QUERY, the only safe method (GET) had no proper body. And every method that did take a body was unsafe. QUERY is the first one to be both. That's the whole reason it exists.


2. Why GET and POST both fall short

Let's use an example every developer has built: product search on an online store.

At first, a GET is perfect:

GET /products?category=shoes&max_price=5000 HTTP/1.1
Host: shop.example

Then the filters grow. Category, price range, brands, rating, availability, sort order, page number. Now the URL looks like this:

GET /products?category=shoes&brand=nike,adidas&min_price=1000&max_price=5000&rating_gte=4&in_stock=true&sort=-popularity&page=3 HTTP/1.1

And it only gets worse from here. Three problems show up.

First, URLs have a size limit. There's no fixed URL length limit. However, the HTTP specification recommends supporting URIs of at least 8,000 octets, and different browsers, servers, proxies, and CDNs may enforce their own limits. Any one of them can reject a URL that's too long.

Second, URLs leak. They get saved in browser history and bookmarks. They land in proxy logs and server logs. So if a filter contains something private, like a customer email or an internal ID, it spreads everywhere. A request body is far less likely to be logged.

Third, long filters are painful to encode. Nested objects and arrays have to be squeezed into one long, fragile URL string.

So teams reach for the obvious fix: POST /products/search with a JSON body.

POST /products/search HTTP/1.1
Host: shop.example
Content-Type: application/json

{
  "category": "Shoes",
  "brands": ["Nike", "Adidas"],
  "price": { "min": 1000, "max": 5000 },
  "rating": 4,
  "sort": "popularity",
  "page": 2
}

It works. But it doesn't describe what's actually happening.

POST means "process this, it might change something." It is neither safe nor idempotent. So every part of the system has to assume the worst. Caches won't store the response. A client that hits a network blip won't retry on its own. And a teammate reading the code can't tell whether POST /products/search reads data or writes it.

Why not just put a body on GET? People try. Don't. A GET body has no defined meaning, and real proxies, load balancers, and CDNs quietly drop it. A GET with a body is a landmine.

So we're stuck between two bad options: a GET that can't hold the query, or a POST that hides what it's really doing. That's the gap QUERY was built for.


3. Enter QUERY

In 2026, the HTTP team introduced a new method called QUERY in RFC 10008.

Take that same product search and write it with QUERY:

QUERY /products HTTP/1.1
Host: shop.example
Content-Type: application/json

{
  "category": "Shoes",
  "brands": ["Nike", "Adidas"],
  "price": { "min": 1000, "max": 5000 },
  "rating": 4,
  "sort": "popularity",
  "page": 2
}

Pretty nice, right? Like POST, the filters travel in the body, so they can be as big and structured as you want. But unlike POST, QUERY tells everyone: "This request only reads data."

Think about what that promise buys you. Because QUERY is safe, browsers and servers can treat it just like GET. If the network drops halfway through, the client can safely retry. If the same search comes in again, a cache can answer it. And anyone reading QUERY /products knows immediately that it reads, and changes nothing. That's something POST simply can't offer.

In one line: you get the request body from POST without losing the guarantees that make GET so useful.


4. Why didn't HTTP already have this?

This was my first reaction reading the RFC: wait, we've been abusing POST for searches for years. Why didn't we have this already?

Honestly, I didn't expect to see a brand-new HTTP method again. PATCH landed in 2010, and I think most of us quietly assumed that was the last one we'd get.

The reason it's so rare comes down to reach. A new method only works if the whole web understands it. Every browser has to send it. Every CDN has to pass it through. Every proxy has to route it. Every framework has to expose it. Getting all of that to move at once is a slow, hard thing, which is why the list barely changes.

Did you know? PATCH arrived in 2010. QUERY is the first brand-new HTTP method in 16 years.


5. GET vs POST vs QUERY

The real difference is about two things: where the filters go, and whether the request is a read.

And here's the honest comparison, straight from the spec:

PropertyGETQUERYPOST
Safe (read-only)yesyesmaybe not
Idempotent (retry-safe)yesyesmaybe not
Filters travel inthe URLthe bodythe body
Cacheableyesyesonly for a later GET/HEAD
Bodyno meaningexpectedexpected

Looking at that table, QUERY almost feels like the missing piece between GET and POST. It reads like GET and carries a body like POST, and it takes the better answer from each column.


6. A worked example

Let's follow one real request from start to finish. A client searches for shoes, filtered a few different ways:

QUERY /products HTTP/1.1
Host: shop.example
Content-Type: application/json
Accept: application/json

{ "category": "Shoes", "rating": 4, "sort": "popularity", "limit": 10 }

The server runs the search and returns the results as normal content:

HTTP/1.1 200 OK
Content-Type: application/json

[
  { "id": 12, "name": "Trail Runner", "price": 4200, "rating": 4.6 },
  { "id": 31, "name": "City Sneaker", "price": 2800, "rating": 4.3 }
]

That's it. A normal request, and a normal response.

The same thing in Node.js

Here's a small Node.js server. It answers the search as a GET (filters from the URL) and as a QUERY (filters from the body), so you can see the only real difference: where the input comes from.

import { createServer } from 'node:http';

const products = [
  { id: 12, name: 'Trail Runner', category: 'Shoes', price: 4200, rating: 4.6 },
  { id: 31, name: 'City Sneaker', category: 'Shoes', price: 2800, rating: 4.3 },
  {
    id: 44,
    name: 'Office Loafer',
    category: 'Formal',
    price: 5600,
    rating: 3.9,
  },
];

// read the whole request body into a string
function readBody(req) {
  return new Promise((resolve) => {
    let data = '';
    req.on('data', (chunk) => (data += chunk));
    req.on('end', () => resolve(data));
  });
}

function search(filters) {
  return products.filter((p) => {
    if (filters.category && p.category !== filters.category) return false;
    if (filters.rating && p.rating < filters.rating) return false;
    return true;
  });
}

const server = createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost');

  // GET: filters come from the URL
  if (req.method === 'GET' && url.pathname === '/products') {
    const filters = {
      category: url.searchParams.get('category'),
      rating: Number(url.searchParams.get('rating')) || 0,
    };
    return sendJson(res, search(filters));
  }

  // QUERY: filters come from the body
  if (req.method === 'QUERY' && url.pathname === '/products') {
    if (!req.headers['content-type']) {
      res.writeHead(400); // Content-Type is required for QUERY
      return res.end('Missing Content-Type');
    }
    const filters = JSON.parse(await readBody(req));
    return sendJson(res, search(filters));
  }

  res.writeHead(405, { Allow: 'GET, QUERY, OPTIONS' });
  res.end();
});

function sendJson(res, data) {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(data));
}

server.listen(8080);

The two handlers are almost the same. GET reads url.searchParams; QUERY reads the body. Same data out, but QUERY now says honestly what it does.

From the client's point of view, almost nothing changes. You just use a different HTTP method:

// GET, filters in the URL
await fetch('/products?category=Shoes&rating=4');

// QUERY, filters in the body, still safe and retry-friendly
await fetch('/products', {
  method: 'QUERY',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    category: 'Shoes',
    rating: 4,
    sort: 'popularity',
    limit: 10,
  }),
});

And because backend developers basically live in the terminal, here it is with curl:

curl -X QUERY http://localhost:8080/products \
  -H "Content-Type: application/json" \
  -d '{"category":"Shoes","rating":4}'

Heads up: whether QUERY actually reaches your handler depends on your runtime, framework, and every proxy in between knowing the method. Support is still landing, see section 10.


7. Caching, the real payoff

Here's the part I find genuinely useful, and the reason QUERY is more than a tidier POST.

Because QUERY is safe, its responses can be cached. You can put a search endpoint behind a CDN, and repeated searches get served from the cache instead of hitting your database every single time.

There's one twist, though.

A GET cache uses the URL as its key. But two QUERY requests can share the exact same URL and differ only in the body. So the cache has to key on the body too.

The rule: the cache key for a QUERY must include the request body, not just the URL.

The spec also gives QUERY two optional response headers that hand the client a plain GET URL for later:

  • Location points to the query itself. GET it to run the same search again.
  • Content-Location points to this exact result. GET it to fetch the same results.

So the client sends the heavy QUERY once, then falls back to a cheap, cacheable GET for repeats. That combination is exactly what POST could never give you.


8. Content types and status codes

QUERY leans hard on the Content-Type header, because the body is the query. The server has to know how to read it. Is it JSON? A form? SQL?

Required: a QUERY with no Content-Type is invalid. The server must reject it, not guess.

The spec maps common failures to clear status codes:

SituationStatus code
No Content-Type, or it disagrees with the body400 Bad Request
The format is known, but not for this query415 Unsupported Media Type
The body is valid, but the query can't run (no such row)422 Unprocessable Content
The response format the client asked for isn't available406 Not Acceptable

On a 415, the server can tell the client which formats it does accept using the Accept-Query header. More on that next.


9. Discovering support

Because QUERY is new, a client often needs to ask a simple question first: do you speak QUERY here, and in what format? There are two clean ways to find out.

OPTIONS lists the methods a resource supports:

OPTIONS /products HTTP/1.1
Host: shop.example
HTTP/1.1 200 OK
Allow: GET, QUERY, OPTIONS, HEAD

Accept-Query is a new response header that says which query formats the resource understands:

HTTP/1.1 200 OK
Accept-Query: application/json, application/sql

Or you can just try a QUERY. If it comes back 415, read the Accept-Query header to learn what to send. If the server doesn't support QUERY at all, it replies 405 Method Not Allowed.


10. Should you use QUERY today?

Here's the practical answer most readers actually want. Pick the method by the job:

Use GET when…Use QUERY when…Use POST when…
Small searchesHuge search filtersYou create a resource
Shareable URLsComplex JSON queriesYou upload a file
Public APIsSensitive filtersAnything that changes data
Simple linksInternal servicesNon-repeatable actions

Now the honest part about support today:

  • QUERY is the first new method since PATCH (2010), so frameworks, gateways, CDNs, and client libraries are all still catching up.
  • Browsers treat QUERY as a non-simple method, so cross-origin calls trigger a CORS preflight (an extra check) until it lands on the safe list.
  • An old proxy somewhere in the path may not recognize QUERY, and could reject it.

So what would I actually do? Use QUERY where you control the whole stack, like internal services or your own gateway. Keep a POST fallback for the public internet until support catches up. That's roughly how PATCH rolled out too, and it took years to become universal.

As framework and proxy support improves, using QUERY for complex read-only operations will become much more practical.


11. Common misconceptions

"QUERY is just POST with a new name." No. POST is neither safe nor idempotent. QUERY is both, which is exactly why it can be cached and safely retried.

"QUERY replaces GET." No. For small, shareable searches, GET is still better. The URL is a link you can copy and bookmark. QUERY is for when the query is too big or too private to sit in a URL.

"I'll just put a body on my GET." Please don't. A GET body has no defined meaning and gets dropped by real proxies and caches. That unreliability is why QUERY exists.

"QUERY can change data if I want it to." It must not. QUERY is a read. If your request changes something, it isn't a QUERY. Use POST, PUT, or PATCH.


12. TL;DR

  • Searches are reads, but GET can't carry a big body and POST isn't safe.
  • QUERY (RFC 10008) is a safe, cacheable method that takes its input from the body.
  • Because it's safe, responses are cacheable and requests are retry-safe, which POST can't promise.
  • The cache key has to include the body. Location lets clients switch to a plain GET for repeats.
  • Content-Type is required. Find support through OPTIONS and Accept-Query.
  • Use it where you control the stack today; the rest of the web is still catching up.

For years, developers have used POST for read-only searches, simply because there wasn't a better option.

QUERY doesn't replace GET. It doesn't replace POST either. It finally gives us the method we've been missing all along.

Whether it becomes as common as GET and POST is something only time will answer. But understanding it today will make you a better API designer tomorrow.