HTTP QUERY: The New Method That Fixes Query Semantics

I was studying software architecture again when I came across the news: the Internet Engineering Task Force, or IETF, had officially published a new HTTP method: QUERY.

That immediately reminded me of a small discomfort that many developers may have noticed over the years:

When an API needs to perform a complex query, which HTTP method should it use?

When the query is simple, the answer seems obvious:

GET /products?category=books&limit=10&sort=price
Host: example.com

So far, so good.

GET is the classic method for retrieving data. It is simple, cacheable, easy to share, and it clearly communicates the intention: “give me a representation of this resource.”

But as applications grew in size and complexity, other very common patterns started to emerge.

  1. Filters got bigger;
  2. Searches started to include multiple conditions;
  3. Payloads became JSON. A little POST here, a little POST there, and suddenly everything is a POST;
  4. URLs became huge, ugly, hard to read, hard to log safely, and in some cases, simply impractical.

So it became very common to see APIs doing this:

POST /products/search
Host: example.com
Content-Type: application/json

{
  "category": "books",
  "price": {
    "min": 20,
    "max": 100
  },
  "tags": ["architecture", "backend"],
  "sort": "createdAt"
}

Does it work? Yes.

Is it semantically correct? Kind of.

In June 2026, the IETF published RFC 10008, which standardizes a new HTTP method called QUERY. The proposal is to allow query requests to carry a body while preserving the semantics of a safe and idempotent operation.

In other words, the server processes the content sent by the client and returns a result, but the client does not expect that request to change the state of the resource being queried.

The problem was more semantic than technical.

HTTP methods are not only about transporting data. They are also a contract of intent.

When we use each method, we are saying something:

GET
= "I want to retrieve something."

POST
= "I want to send something for processing, possibly creating or changing state."

Of course, in practice, POST is used for almost everything. It is the Swiss Army knife of APIs. It creates users, submits forms, triggers payments, logs people in, runs complex searches, calls GraphQL, sends webhooks, and if you let it, probably makes coffee and cooks dinner too.

But that flexibility comes at a cost.

A POST request can be safe, but the protocol cannot assume that in a generic way. A proxy, cache, HTTP client, or retry system does not automatically know whether POST /search is an innocent search request or an operation that creates a charge on someone’s credit card.

That is the gap QUERY came to fill.

It says this explicitly:

QUERY /products/search
Host: example.com
Content-Type: application/json

{
  "category": "books",
  "price": {
    "min": 20,
    "max": 100
  },
  "tags": ["architecture", "backend"],
  "sort": "-createdAt"
}

The message is now much clearer:

Process this query and return the result.

Notice what is happening here.

By using this method, I am not creating a resource, nor changing state or even asking the server to perform an action with an expected side effect.

I am asking a question.

QUERY is not a better GET

Now, hold on. Do not rush the conclusion.

I am sure some of you may already be thinking:

So should we replace GET with QUERY now?

No.

GET is still perfect for simple queries, shareable URLs, bookmarkable links, and clearly addressable resources.

For example:

GET /articles/http-query
Host: example.com

Or:

GET /products?category=books&page=2
Host: example.com

These cases still belong to GET.

This is where the decision becomes important: QUERY comes in when the query needs a body.

This approach is especially useful when expressing the query in the URL starts to lose naturalness, safety, or efficiency. The RFC highlights three main reasons:

Some kinds of data are inefficient to represent in a valid URI.

URLs are usually more exposed in logs and bookmarks than request bodies.

Encoding the query directly into the URI effectively causes each possible combination of parameters to be treated as a distinct resource.

In other words, QUERY does not replace GET.

It replaces that slightly crooked use of POST for operations that were conceptually read-only all along.

Safe and idempotent, and yes, it matters a lot

The RFC defines QUERY as a safe and idempotent method. Translated into plain English: it was designed to represent a question, not an action that changes the system.

That matters a lot when a request fails halfway through.

Imagine this scenario:

POST /email-campaigns/send

The app sends a request to trigger an email campaign for 50,000 people. The server receives the request and starts processing the send, but the connection drops before the app receives the response.

From the app’s point of view, there is now uncertainty:

Was the campaign sent or not?

If the app automatically retries the request, it might send the same campaign twice. That is why retrying a POST without care can be dangerous.

Not because every POST changes state, but because the protocol does not guarantee that it is only a query.

Now compare that with:

QUERY /email-campaigns/audience-preview

Here, the intention is different. The client is not saying “send this campaign,” “change the budget,” or “modify the contact list.”

It is saying: “simulate this audience using these filters and return a result.”

For example:

QUERY /email-campaigns/audience-preview
Content-Type: application/json

{
  "campaignId": "summer-sale-2026",
  "filters": {
    "countries": ["BR", "US"],
    "segments": ["active-customers", "newsletter-subscribers"],
    "excludeSegments": ["recent-buyers", "unsubscribed"],
    "minEngagementScore": 70,
    "lastInteraction": {
      "after": "2026-01-01"
    }
  }
}

If this request fails and gets sent again, the expected behavior is simply that the server recalculates the audience preview. The retry should not send emails, duplicate campaigns, alter contacts, or modify the main state of the system.

Analogously, we could think about it like this:

POST /email-campaigns/send
= "Send this campaign to 50,000 people."

QUERY /email-campaigns/audience-preview
= "Simulate how many people would receive this campaign with these filters."

If you repeat the first instruction twice, you might send the same campaign twice to the same audience.

If you repeat the second instruction twice, you simply see the same audience preview twice.

That is the importance of semantics.

QUERY communicates to clients, servers, proxies, and intermediary tools that the request can be safely repeated because it represents a query. A POST, even when used for search, does not carry that guarantee by default.

In practice, this allows HTTP clients and infrastructure layers to handle retries, temporary failures, and interrupted connections more intelligently, without guessing whether an operation was a simple read or an action with side effects.

What about cache?

This is where things get interesting.

With GET, the query is usually represented directly in the URL:

GET /products?category=books&sort=price

If we change the filters, we change the URL:

GET /products?category=games&sort=price

That fits very naturally with traditional HTTP caching. The URL already carries the identity of the query. To the cache, these are two different requests because they are two different addresses.

With QUERY, the situation changes a bit. The URL can stay the same while the query changes in the request body:

QUERY /products
Content-Type: application/json

{
  "category": "books",
  "sort": "price"
}
QUERY /products
Content-Type: application/json

{
  "category": "games",
  "sort": "price"
}

In both cases, the endpoint is /products.

What differentiates one query from the other is not the URL. It is the content sent in the body.

That means caching QUERY responses requires more care. A cache cannot consider only the method and the URL. It needs to include the request body in the cache key, otherwise it risks returning the result for books when the client asked for games.

With GET, the URL is usually enough to identify the query.

With QUERY, the identity of the query also depends on the body.

To address this, the RFC defines that the cache key for a QUERY request needs to incorporate the request content, along with other relevant metadata.

Wait, but how would this cache key actually work?

When we talk about a cache key, we are not talking about something the user sees, nor necessarily about a header sent by the client. A cache key is an internal identifier used by the system that stores and reuses responses.

At a very basic level, a cache works like a dictionary:

cache[key] = response

When a new request arrives, the cache builds a key for it and checks whether it already has a saved response for that same key.

With GET, this is usually simple because the query is represented in the URL. Using the previous example, the cache might build an internal key like this:

GET:/products?category=books&sort=price

If another identical request arrives, the key will be the same, so the cache can return the stored response.

With QUERY, the URL alone is not enough. Two queries can use the same endpoint while carrying different questions in the request body. If the cache considered only the method and the URL, both requests would fall into the same key, something like this:

QUERY:/products

That would be dangerous. The cache could return the result for books when the client asked for games.

That is why, with QUERY, the body content needs to become part of the request identity. One common way to do that is to generate a hash of the body and include that hash in the internal cache key.

Conceptually, it would look like this:

QUERY:/products:application/json:body-hash=a1b2c3

For the books query, the cache could store:

cache["QUERY:/products:application/json:body-hash=a1b2c3"] = books_response

For the games query, the body would be different, the hash would also be different, and the key would be separate:

cache["QUERY:/products:application/json:body-hash=d4e5f6"] = games_response

In other words, the cache is not storing only “the response for /products.”

It is storing “the response for /products with this specific query.”

A Node.js example could look like this:

import crypto from "node:crypto";

// Always type your data, folks.
type CacheableRequest = {
  method: string;
  path: string;
  headers: Record<string, string>;
  body: unknown;
};

// Create the cache key from the request.
function createCacheKey(request: CacheableRequest): string {
  // Serialize the request body into a string.
  const normalizedBody = JSON.stringify(request.body);

  // Hash the body.
  const bodyHash = crypto
    .createHash("sha256")
    .update(normalizedBody)
    .digest("hex");

  // Return the pieces that identify this request.
  return [
    request.method,
    request.path,
    request.headers["content-type"] ?? "",
    bodyHash,
  ].join(":");
}

And the full flow would look like this:

const key = createCacheKey(request);

const cachedResponse = await cache.get(key);

if (cachedResponse) {
  return cachedResponse;
}

const response = await executeQuery(request.body);

await cache.set(key, response, {
  ttl: 60,
});

return response;

In practice, this can happen in several places: inside the application, in an API gateway, in a proxy, or in a CDN that supports custom cache-key logic.

The important point is that, with QUERY, traditional caches that look only at method and URL are not enough. To cache safely, the system needs to consider the request body as well. Otherwise, different queries could be treated as the same request.

The new Accept-Query header

Along with the method, there is also a new header: Accept-Query.

It allows a server to communicate which formats it accepts in the body of a QUERY request. IANA already registers Accept-Query as a permanent HTTP field, referenced by RFC 10008.

Example:

Accept-Query: application/json, application/graphql

In spirit, this is similar to headers like Accept-Patch or Accept-Post: the server tells the client which formats make sense for that kind of operation.

In a real API, this could help with capability discovery:

OPTIONS /products/search
Host: example.com

Response:

HTTP/1.1 204 No Content
Allow: GET, QUERY, OPTIONS
Accept-Query: application/json

Now the client knows that this resource accepts QUERY with JSON.

Where QUERY makes sense

The best place for QUERY is where you currently use POST with a little bit of technical shame.

Some examples:

Complex searches:

QUERY /orders/search
Content-Type: application/json

{
  "status": ["paid", "shipped"],
  "createdAt": {
    "from": "2026-01-01",
    "to": "2026-06-30"
  },
  "customer": {
    "country": "BR",
    "segment": "enterprise"
  }
}

Analytics APIs:

QUERY /metrics
Content-Type: application/json

{
  "dimensions": ["campaign", "platform"],
  "metrics": ["spend", "clicks", "conversions"],
  "filters": {
    "dateRange": "last_30_days"
  }
}

Semantic search:

QUERY /documents/search
Content-Type: application/json

{
  "query": "HTTP methods with request body",
  "limit": 20,
  "rerank": true
}

GraphQL is also a natural candidate, especially when the operation is a read-only query.

Daniel Stenberg, creator of curl, summarizes it in a very practical way:

For many use cases, you can think of QUERY as a kind of GET with a body. It looks similar to POST, but uses a different method and carries idempotent semantics.

Where QUERY should not be used yet

Even though it is standardized, that does not mean it is ready to show up in every production system tomorrow morning.

The first reason is support.

Many frameworks, proxies, firewalls, gateways, CDNs, SDKs, observability tools, and libraries may not recognize the new method correctly yet. Some environments block unknown HTTP methods by default. Others may accept the request but fail around logging, tracing, CORS, caching, or OpenAPI documentation.

The second reason is client compatibility.

If you expose a public API consumed by third parties, adopting QUERY too early may create friction. Your consumer may be able to send GET and POST easily, but may need infrastructure changes just to support a new method.

The third reason is product design.

Not every query deserves a body. If the filter fits naturally in the URL and makes sense as a link, use GET. A readable URL is still one of the best interfaces the web ever gave us.

One important detail: CORS and tooling

In the browser, methods other than GET, HEAD, and POST generally require a CORS preflight. Since QUERY is a new method, expect cross-origin calls to go through OPTIONS before the actual request.

That is not a problem, but it is something that needs to be part of the API design.

Tooling support will also arrive gradually. In the case of curl, it is already possible to send a request using -X QUERY, but Stenberg has pointed out that redirects deserve attention and that you should be careful with versions and options when following redirects.

Simple example:

curl -X QUERY https://api.example.com/products/search \
  -H "Content-Type: application/json" \
  -d '{"category":"books","limit":10}'

Translated into plain English, this is roughly saying:

Dear curl,

Run the QUERY method against:
https://api.example.com/products/search

Send this header, which identifies the body as JSON:
Content-Type: application/json

And do not forget to send this body:
{"category":"books","limit":10}

This works as an experiment. For production, the right question is not “can curl send it?”

The right question is:

Does the entire path between client and server understand this correctly?

What this changes for API design

QUERY is a vocabulary correction.

Before it, we mainly had two options:

GET
= communicates reading, but was not designed to carry complex query content in the body.

POST
= accepts a body easily, but communicates a broader and potentially state-changing intent.

QUERY enters as a third option:

GET
= retrieve a representation identified by the URI

POST
= send content for processing, often with a state change

QUERY
= send a query in the body and receive a result, with no expected state change

That improves the expressiveness of the protocol.

And expressiveness matters.

Good APIs are not just APIs that work. They are APIs that communicate intent clearly to humans, clients, caches, proxies, gateways, and observability tools.

So should I use QUERY now?

It depends on the context.

Use QUERY in experiments, internal APIs, controlled environments, and cases where you control the client, server, and infrastructure. It is excellent for designing more accurate contracts in new systems.

For public APIs, I would be more conservative. Document the intention, test support end to end, and maybe offer POST as a fallback for a while.

For mature APIs, there is no need to start migrating everything immediately.

The biggest immediate benefit of QUERY may be conceptual, since it forces us to look at endpoints like:

POST /search
POST /graphql
POST /reports

And ask:

Does this actually modify anything, or are we using POST because we did not have a better word?

Now the word exists.

Final thought

Well, friends…

HTTP did not get a new method because someone wanted to make developers’ lives more complicated. It got a new method because the web matured enough to admit an old workaround.

We are maturing and realizing that we probably should not have been using POST to ask questions.

QUERY says, with protocol-level precision:

Asking is not posting.

Like every protocol-level novelty, adoption will be gradual. First come the experiments. Then the frameworks. Then the gateways. Then the quiet normalization.

The direction, however, is excellent. Because in the end, good systems do not depend only on working code. They depend on well-modeled intent.

And few things are more underrated in architecture than giving the right name to something we already do every day.

Leave a Reply

Your email address will not be published. Required fields are marked *