Skip to content

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.

Builds on Caching and Idempotency and Retries

Stage 8core11 min read

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. Use it today in services you control. The rest of the web will catch up over the next few years.


1. A quick refresher: safe and idempotent

An HTTP method is the verb of a request, like GET or POST. Two words describe every method, and QUERY depends on both. Let's get them straight first.

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. You can repeat a safe request, cache its answer, or load it early, and nothing breaks.

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 matters because it makes retries safe. Say a request times out and you don't know if it worked. 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
POSTnonot by defaultyes
PUTnoyesyes
PATCHnonoyes
DELETEnoyesrarely
QUERYyesyesyes

Look at the table. Before QUERY, the only safe method (GET) had no proper body. Every method that did take a body was unsafe. QUERY is the first to be both safe and able to carry a body. 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, long URLs get rejected. HTTP has no fixed URL length limit. The specification does recommend supporting URIs (URLs) of at least 8,000 octets (bytes). But browsers, servers, proxies, and CDNs (networks of caching servers) may set their own limits. Any one of them can reject a URL that is too long.

Second, URLs leak. They get saved in browser history and bookmarks. They land in proxy logs and server logs. If a filter holds 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 not safe, and not idempotent by default (a server can make one particular POST idempotent, but nobody else can tell). 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. A teammate reading the code can't tell if 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."

That promise buys you three things. Because QUERY is safe, browsers and servers can treat it 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 it only reads. POST can't offer that.

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 is 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 (a middle server that forwards requests) has to route it. Every framework has to expose it. Getting all of them to move together is slow, so the list barely changes.

Loading diagram…

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 difference comes down to two questions: where do the filters go? and is the request only a read?

Loading diagram…

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

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

QUERY is the missing piece between GET and POST. It reads like GET and carries a body like POST. It takes the better answer from each column.


6. A worked example

Let's follow one request from start to finish. A client searches for shoes with rating 4 or more, sorted by popularity, 10 results:

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');
    }
    let filters;
    try {
      filters = JSON.parse(await readBody(req));
    } catch {
      res.writeHead(400);
      return res.end('Body must be valid JSON');
    }
    if (typeof filters !== 'object' || filters === null) {
      res.writeHead(400);
      return res.end('Body must be a JSON object');
    }
    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 with readBody, which collects the body chunks into one string, then JSON.parse turns it into an object. A body that is not valid JSON, or not a JSON object, gets a 400. Both call the same search function. If the method is neither, the server answers 405 and lists the allowed methods in the Allow header. Same data out, but QUERY says honestly what it does.

For the client, almost nothing changes. You just pick a different 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,
  }),
});

Here it is with curl too. The -X flag sets the method and -d sets the body:

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

Heads up: QUERY only reaches your handler if your runtime, framework, and every proxy in between know the method. Support is still landing, see section 10.


7. Caching, the real payoff

This is the reason QUERY is more than a tidier POST.

Because QUERY is safe, its responses can be cached (stored and reused). Put a search endpoint behind a CDN, and repeated searches come from the cache. Your database is not hit every time.

There's one twist.

A GET cache uses the URL as its key, the label it files each answer under. But two QUERY requests can have the same URL and differ only in the body: one searches shoes, the other searches shirts. 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.
Loading diagram…

So the client sends the heavy QUERY once, then uses a cheap, cacheable GET for repeats. POST could never give you that.


8. Content types and status codes

The Content-Type header says what format the body is in. QUERY needs it, because the body is the query and the server must 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 may need to ask first: does this server support QUERY, and in what format? There are two 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 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?

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 state of support today:

  • QUERY is the first new method since PATCH (2010), so frameworks, gateways, CDNs, and client libraries are still catching up.
  • Browsers treat QUERY as a non-simple method. A cross-origin call (to a different website domain) triggers a CORS preflight, an extra check request sent first, until QUERY joins the safe list.
  • An old proxy somewhere in the path may not recognize QUERY, and could reject it.

So what would I 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. PATCH rolled out the same way, and it took years to become universal.

As support improves, QUERY will be easier to use for complex read-only operations.


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 proxies and load balancers may quietly drop it. 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 used POST for read-only searches because there was no better option.

QUERY doesn't replace GET or POST. It gives us the method we were missing.

Only time will tell if it becomes as common as GET and POST. Understanding it today will still make you a better API designer.

In this topic

Done with this topic?

How well do you know it?

PreviousThe CAP Theorem