You're building a search API.
At first,
GETworks 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
GETandPOSTboth 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
GETserver and aQUERYserver. - Why QUERY being cacheable is the whole point.
- When to use
GET,QUERY, orPOSTin 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 likePOST.
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:
| Method | Safe? | Idempotent? | Has a body? |
|---|---|---|---|
GET | yes | yes | no defined meaning |
POST | no | not by default | yes |
PUT | no | yes | yes |
PATCH | no | no | yes |
DELETE | no | yes | rarely |
QUERY | yes | yes | yes |
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.exampleThen 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.1And 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. AGETbody has no defined meaning, and real proxies, load balancers, and CDNs quietly drop it. AGETwith 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
POSTwithout losing the guarantees that makeGETso 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.
Did you know?
PATCHarrived 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?
And here's the honest comparison, straight from the spec:
| Property | GET | QUERY | POST |
|---|---|---|---|
| Safe (read-only) | yes | yes | no |
| Idempotent (retry-safe) | yes | yes | not by default |
| Filters travel in | the URL | the body | the body |
| Cacheable | yes | yes | only for a later GET/HEAD |
| Body | no meaning | expected | expected |
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:
Locationpoints to the query itself.GETit to run the same search again.Content-Locationpoints to this exact result.GETit to fetch the same results.
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-Typeis invalid. The server must reject it, not guess.
The spec maps common failures to clear status codes:
| Situation | Status code |
|---|---|
No Content-Type, or it disagrees with the body | 400 Bad Request |
| The format is known, but not for this query | 415 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 available | 406 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.exampleHTTP/1.1 200 OK
Allow: GET, QUERY, OPTIONS, HEADAccept-Query is a new response header that says which query formats the resource understands:
HTTP/1.1 200 OK
Accept-Query: application/json, application/sqlOr 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 searches | Huge search filters | You create a resource |
| Shareable URLs | Complex JSON queries | You upload a file |
| Public APIs | Sensitive filters | Anything that changes data |
| Simple links | Internal services | Non-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.
POSTis 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,
GETis 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
GETbody 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, orPATCH.
12. TL;DR
- Searches are reads, but
GETcan't carry a big body andPOSTisn'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
POSTcan't promise. - The cache key has to include the body.
Locationlets clients switch to a plainGETfor repeats. Content-Typeis required. Find support throughOPTIONSandAccept-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.