HTTP QUERY Method

A new HTTP verb for safe, idempotent, cacheable queries with a request body. Closes the gap between GET and POST for complex search APIs.

✓ Safe ✓ Idempotent ✓ Cacheable ✓ Body allowed ⚠ CORS preflight required
RFC: RFC 10008 — The HTTP QUERY Method  ·  Published: October 2024  ·  Authors: J. Reschke, A. Malhotra, J. Snell

The problem: GET is safe and cacheable but can't carry a body. POST can carry a body but is not safe (caches treat it as state-mutating). For complex search APIs, this forces developers to either stuff queries into URIs (brittle, length-limited, logged in cleartext) or use POST with search semantics (un-cacheable, semantically wrong).

Safe
Does not change server state. Caches, proxies, and retry logic treat it like GET — safe to replay automatically.
Body carries the query
Any format: application/json, application/sql, application/graphql, application/x-www-form-urlencoded, custom DSLs.
Cacheable
RFC 10008 §2.2: responses are cacheable. Cache key = URI + Content-Type + SHA-256(body). Shared proxies can cache search results.
Content-Location
Server can return a Content-Location URI so clients can later GET the same results without resending the body.
Accept-Query
New header (§3): server advertises which query formats it accepts. Returned on 415 responses so clients can adapt.
Idempotent
Identical QUERY requests MUST produce identical results. Retry on timeout is safe — no risk of duplicate side effects.
This demo runs a real Go implementation compiled to WebAssembly. The pkg/query package implements RFC 10008 QUERY handling including Content-Type validation, 415 + Accept-Query negotiation, and 422 for malformed query bodies. The cache demo uses pkg/cache with a SHA-256 body-keyed store.
Quick comparison
Property GET QUERY POST (search)
Safe (no state change) Yes Yes No
Idempotent Yes Yes No
Cacheable by default Yes Yes No
Carries a request body Undefined Yes Yes
Query in URI Required Optional No
Accept-Query negotiation No Yes No

Protocol Flow

A QUERY request flows through client → (optional proxy cache) → origin server. The animation shows all three paths: cache miss, cache hit, and the Accept-Query negotiation on unsupported formats.

Click a button below to animate the flow Client application Proxy Cache SHA-256 keyed Origin Server QUERY handler QUERY + body → → forwarded ← response
1
Client sends QUERY /users HTTP/1.1 with Content-Type: application/json and a query body.
2
Proxy cache computes cache key = SHA-256(URI + Content-Type + body). On MISS, forwards to origin with the full body intact.
3
Origin validates Content-Type is present and supported. Runs the query. Returns 200 OK with Content-Location URI for the result set.
4
Proxy caches the response under the computed key. Client and proxy both receive X-Cache: MISS. Next identical QUERY → X-Cache: HIT, no backend call.

QUERY vs GET vs POST

See exactly how the same complex query looks in each HTTP method — and why QUERY is the right choice for search APIs.

Live Comparison
Click "Compare methods" to run the WASM comparison...
Code examples — same query, three methods
# ─── GET (query in URI — breaks at ~2000 chars) ───────────────────
curl -G "https://api.example.com/users" \
  --data-urlencode 'filter={"status":"active","dept":["Engineering"]}' \
  --data-urlencode 'sort=name:asc' \
  --data-urlencode 'limit=50'

# ─── POST (not safe, not cacheable) ───────────────────────────────
curl -X POST "https://api.example.com/users/search" \
  -H "Content-Type: application/json" \
  -d '{"filter":{"status":"active","dept":["Engineering"]},"sort":[{"field":"name","dir":"asc"}],"limit":50}'

# ─── QUERY (safe + cacheable + body) ──────────────────────────────
curl -X QUERY "https://api.example.com/users" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"filter":{"status":"active","dept":["Engineering"]},"sort":[{"field":"name","dir":"asc"}],"limit":50}'
package main

import (
  "bytes"
  "encoding/json"
  "net/http"
)

func queryUsers(client *http.Client, baseURL string) (*http.Response, error) {
  body, _ := json.Marshal(map[string]interface{}{
    "filter": map[string]interface{}{
      "status": "active",
      "dept":   []string{"Engineering"},
    },
    "sort":  []map[string]string{{"field": "name", "dir": "asc"}},
    "limit": 50,
  })

  req, _ := http.NewRequest("QUERY", baseURL+"/users", bytes.NewReader(body))
  req.Header.Set("Content-Type", "application/json")
  req.Header.Set("Accept", "application/json")

  // QUERY is safe + idempotent — the client can retry on timeout
  return client.Do(req)
}

// Server handler (uses pkg/query from this project)
import "github.com/jralmaraz/http-query-method/pkg/query"

handler := query.JSONQueryHandler(func(q map[string]interface{}) (interface{}, error) {
  results := db.Query(q["filter"], q["sort"], q["limit"])
  return map[string]interface{}{"results": results}, nil
})
// ─── Browser / Node.js (fetch API) ───────────────────────────────
const response = await fetch('https://api.example.com/users', {
  method: 'QUERY',         // RFC 10008 method
  headers: {
    'Content-Type': 'application/json',
    'Accept':       'application/json',
  },
  body: JSON.stringify({
    filter: { status: 'active', dept: ['Engineering'] },
    sort:   [{ field: 'name', dir: 'asc' }],
    limit:  50,
  }),
  // Note: QUERY is not a CORS-safelisted method — preflight will fire
});

const data = await response.json();
console.log(`Cache: ${response.headers.get('X-Cache')}`);      // MISS or HIT
console.log(`Results URI: ${response.headers.get('Content-Location')}`);

// ─── Node.js with undici (explicit QUERY support) ─────────────────
import { fetch } from 'undici';   // undici allows custom methods
const res = await fetch(url, { method: 'QUERY', body, headers });
import httpx  # httpx supports custom methods natively

query_body = {
    "filter": {"status": "active", "dept": ["Engineering"]},
    "sort":   [{"field": "name", "dir": "asc"}],
    "limit": 50,
}

# httpx allows any HTTP method string
response = httpx.request(
    method="QUERY",
    url="https://api.example.com/users",
    json=query_body,
    headers={"Accept": "application/json"},
)

data = response.json()
print(f"Cache: {response.headers.get('X-Cache')}")          # MISS or HIT
print(f"Results: {data['results']}")

# Flask server handler
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/users', methods=['QUERY'])
def query_users():
    query = request.get_json()
    if not query:
        return jsonify(error="Content-Type required"), 400
    results = db.search(query["filter"], query.get("limit", 20))
    return jsonify(results=results)
import java.net.URI;
import java.net.http.*;
import java.net.URLEncoder;

// ─── GET (query in URI — breaks at ~2000 chars) ───────────────────
var client = HttpClient.newHttpClient();
var params = URLEncoder.encode("""
    {"status":"active","dept":["Engineering"]}""", StandardCharsets.UTF_8);
var get = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users?filter=" + params + "&limit=50"))
    .GET()
    .header("Accept", "application/json")
    .build();

// ─── POST (not safe, not cacheable) ───────────────────────────────
var body = """
    {"filter":{"status":"active","dept":["Engineering"]},
     "sort":[{"field":"name","dir":"asc"}],"limit":50}""";
var post = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users/search"))
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .header("Content-Type", "application/json")
    .build();

// ─── QUERY (safe + cacheable + body) — Java 11+ HttpClient ────────
var query = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users"))
    .method("QUERY", HttpRequest.BodyPublishers.ofString(body))
    .headers("Content-Type", "application/json",
             "Accept",       "application/json")
    .build();
var response = client.send(query, HttpResponse.BodyHandlers.ofString());
// QUERY is safe + idempotent → safe to retry on timeout or 5xx

// ─── Spring Boot 6 client (RestClient with custom method) ─────────
@Service
public class UserQueryService {
    private final RestClient rest = RestClient.create();

    public List<User> findUsers(UserFilter filter) {
        return rest
            .method(HttpMethod.valueOf("QUERY"))
            .uri("https://api.example.com/users")
            .contentType(MediaType.APPLICATION_JSON)
            .body(filter)
            .retrieve()
            .body(new ParameterizedTypeReference<List<User>>() {});
    }
}

// ─── Spring Boot 6 handler (server side) ──────────────────────────
@RestController
@RequestMapping("/users")
public class UserController {

    @RequestMapping(method = RequestMethod.valueOf("QUERY"),
                    consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<List<User>> queryUsers(@RequestBody UserFilter filter) {
        var results = userService.search(filter);
        return ResponseEntity.ok()
            .header("Content-Location", "/users/results/" + filter.cacheKey())
            .body(results);
    }
}
use reqwest::Method;

// reqwest supports custom methods via Method::from_bytes
let query_method = Method::from_bytes(b"QUERY").unwrap();

let body = serde_json::json!({
    "filter": {"status": "active", "dept": ["Engineering"]},
    "sort":   [{"field": "name", "dir": "asc"}],
    "limit": 50,
});

let response = client
    .request(query_method, "https://api.example.com/users")
    .header("Content-Type", "application/json")
    .header("Accept",       "application/json")
    .json(&body)
    .send()
    .await?;

// Axum server handler
use axum::{routing::method_routing, Router, Json, http::StatusCode};

async fn query_users(Json(q): Json<serde_json::Value>) -> Json<serde_json::Value> {
    let results = db.search(&q).await;
    Json(serde_json::json!({"results": results}))
}

let app = Router::new()
    .route("/users", method_routing::on(MethodFilter::try_from("QUERY").unwrap(), query_users));

Caching Semantics

RFC 10008 §2.2 defines how QUERY responses are cached. The cache key incorporates the full request body, making QUERY unique among cacheable methods.

Key insight: Unlike GET (where the URI is the cache key), QUERY's cache key is SHA-256(method + URI + Content-Type + body). A proxy must buffer and hash the request body before looking up the cache — this is inherently more expensive than GET cache lookup.
Client sends QUERY Proxy Cache computes key: sha256(URI+CT+body) checking... Origin runs query QUERY + body forward 200 OK (X-Cache: MISS) Click a button to animate
1
Client sends QUERY /users with body. Proxy reads the full body to compute the cache key: SHA-256(URI + Content-Type + body).
2
Cache lookup. On MISS the request is forwarded to the origin server. On HIT the cached response is returned immediately — no backend call.
3
Origin processes the query and returns 200 OK with Cache-Control: max-age=300 and optionally a Content-Location URI for the result set.
4
Proxy stores the response under the computed key. Second request with same bodyX-Cache: HIT, 1ms latency, no origin call. Different body → new MISS.
WASM Cache Demo

Enter a query body. Send it twice to see MISS → HIT behaviour.

Run the demo to see caching in action...
Cache implementation — code examples
// pkg/cache — in-memory QUERY response cache (from this project)
store := cache.NewStore(5 * time.Minute)
handler := cache.NewCachingHandler(queryHandler, store)
// X-Cache: MISS on first request, X-Cache: HIT on repeat

// Cache key derivation (RFC 10008 §2.2)
func Key(uri, contentType string, body []byte) string {
  h := sha256.New()
  h.Write([]byte(uri))
  h.Write([]byte("\x00"))
  h.Write([]byte(contentType))
  h.Write([]byte("\x00"))
  h.Write(body)
  return hex.EncodeToString(h.Sum(nil))
}

// RFC 10008 §2.2: "no-transform" prevents cache normalisation
// Cache-Control: no-transform → use raw body as key, skip whitespace normalisation
# nginx — cache QUERY responses using body hash as key
http {
  proxy_cache_path /var/cache/nginx/query levels=1:2
                   keys_zone=query_cache:10m max_size=1g
                   inactive=5m use_temp_path=off;

  server {
    location /api/ {
      # Allow QUERY to be cached (nginx treats unknown methods as uncacheable by default)
      proxy_cache_methods GET HEAD QUERY;
      proxy_cache query_cache;

      # Cache key: URI + body digest (requires request body digest module)
      proxy_cache_key "$scheme$request_method$host$request_uri$http_content_type$request_body_hash";

      proxy_cache_valid 200 5m;
      add_header X-Cache $upstream_cache_status;

      proxy_pass http://backend;
    }
  }
}
// Varnish VCL — full QUERY caching with body-keyed lookup
import digest;

sub vcl_recv {
  // Allow QUERY to pass through cache lookup (not just GET/HEAD)
  if (req.method == "QUERY") {
    // Buffer the body (requires bereq.body access in VCL 7+)
    set req.http.X-Query-Body-Hash =
      digest.hash_sha256(req.body);

    // Synthesise a cache-lookup key combining URI + body hash
    set req.http.X-Cache-Key =
      req.url + "|" + req.http.Content-Type + "|" + req.http.X-Query-Body-Hash;

    return(hash);   // proceed to cache lookup
  }
}

sub vcl_hash {
  if (req.method == "QUERY") {
    hash_data(req.http.X-Cache-Key);
    return(lookup);
  }
}

sub vcl_deliver {
  set resp.http.X-Cache = if(obj.hits > 0, "HIT", "MISS");
}
No-transform directive
Client sends Cache-Control: no-transform to prevent the cache from normalising the body before computing the key. The raw body is used as-is.
Body buffering cost
A caching proxy must buffer the full request body to compute the key — more expensive than GET. For large bodies (>1MB), streaming proxies may bypass the cache.
Content-Location
Server returns Content-Location: /users/queries/q-abc123. Client can GET that URI later without resending the body — no cache-key computation needed.

Error Response Matrix

RFC 10008 §2 defines specific HTTP status codes for each class of QUERY failure. Correct error responses are essential for client adaptation and debuggability.

WASM Error Simulator
Click a scenario button above...
Complete error reference
StatusTriggerRFC refSpecial header
400 Bad RequestMissing Content-Type header on QUERY request§2
400 Bad RequestContent-Type claims JSON but body is XML (inconsistency)§2
405 Method Not AllowedQUERY sent to a resource that does not support itRFC 9110Allow: GET, HEAD
406 Not AcceptableAccept header lists format server can't produce§2
415 Unsupported Media TypeQuery body format not understood by server§2 + §3Accept-Query: application/json
422 Unprocessable ContentSyntactically valid query but semantically invalid§2
200 OKQuery processed successfully§2Content-Location (optional)
Accept-Query header (RFC 10008 §3): When returning 415, the server MUST include an Accept-Query header listing the media types it does accept. This allows clients to adapt automatically. Example: Accept-Query: application/json, application/x-www-form-urlencoded
Server error handling — code examples
// pkg/query.Handler enforces RFC 10008 error semantics automatically
import "github.com/jralmaraz/http-query-method/pkg/query"

handler := query.NewHandler(
  func(req *query.Request) ([]byte, string, error) {
    // Validate query semantics
    var q SearchQuery
    if err := json.Unmarshal(req.Body, &q); err != nil {
      return nil, "", &query.QueryError{
        Status:  http.StatusUnprocessableEntity,   // 422
        Message: "invalid query: " + err.Error(),
      }
    }
    results := db.Search(q)
    out, _ := json.Marshal(results)
    return out, "application/json", nil
  },
  "application/json",   // supported types → Accept-Query on 415
)
// Missing Content-Type → 400 (automatic)
// Wrong Content-Type  → 415 + Accept-Query header (automatic)
const express = require('express');
const app = express();

// Express doesn't know QUERY natively — register it explicitly
app.all('/users', (req, res) => {
  if (req.method !== 'QUERY') {
    return res.set('Allow', 'QUERY').status(405).json({ error: 'Method Not Allowed' });
  }

  // RFC 10008 §2: validate Content-Type
  const ct = req.headers['content-type'] || '';
  if (!ct) {
    return res.status(400).json({ error: 'Content-Type is required for QUERY' });
  }
  if (!ct.includes('application/json')) {
    return res
      .set('Accept-Query', 'application/json')    // RFC 10008 §3
      .status(415).json({ error: 'Unsupported format' });
  }

  const results = db.search(req.body);
  res.json({ results });
});
from fastapi import FastAPI, Request, Response
from fastapi.routing import APIRoute

app = FastAPI()

# FastAPI allows custom HTTP methods via include_router
@app.api_route("/users", methods=["QUERY"])
async def query_users(request: Request):
    ct = request.headers.get("content-type", "")
    if not ct:
        return Response(
            content='{"error":"Content-Type required"}',
            status_code=400, media_type="application/json"
        )
    if "application/json" not in ct:
        return Response(
            content='{"error":"Unsupported format"}',
            status_code=415, media_type="application/json",
            headers={"Accept-Query": "application/json"}   # RFC 10008 §3
        )
    body = await request.json()
    results = db.search(body)
    return {"results": results}
import org.springframework.web.bind.annotation.*;
import org.springframework.http.*;

@RestController
@RequestMapping("/users")
public class UserController {

    // Register QUERY method — Spring MVC accepts arbitrary method names
    @RequestMapping(method = RequestMethod.valueOf("QUERY"),
                    consumes = MediaType.APPLICATION_JSON_VALUE,
                    produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<?> queryUsers(
            HttpServletRequest req,
            @RequestBody(required = false) UserFilter filter) {

        // 400 — missing Content-Type or body (Spring throws HttpMediaTypeNotSupportedException)
        if (filter == null) {
            return ResponseEntity.badRequest()
                .body(Map.of("error", "Content-Type and body required"));
        }

        // Business logic — safe to retry (QUERY is idempotent)
        var results = userService.search(filter);

        // 200 — include Content-Location for the result URI (RFC 10008 §4)
        return ResponseEntity.ok()
            .header("Content-Location", "/users/results/" + filter.cacheKey())
            .body(results);
    }

    // 415 — wrong Content-Type; Spring auto-sends this but you can customise:
    @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
    public ResponseEntity<?> handleUnsupportedMedia() {
        return ResponseEntity.status(415)
            .header("Accept-Query", "application/json")  // RFC 10008 §3
            .body(Map.of("error", "Unsupported format; use application/json"));
    }

    // 405 — if someone sends GET or POST to this endpoint
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public ResponseEntity<?> handleWrongMethod() {
        return ResponseEntity.status(405)
            .header("Allow", "QUERY, OPTIONS")
            .body(Map.of("error", "Use QUERY method for search operations"));
    }
}

Database & Backend Integration

The QUERY method is particularly powerful for database-backed APIs — any query language can be the body: SQL, GraphQL, JSONPath, or a custom DSL. See how different backends expose the QUERY method.

Client any language HTTP Server pkg/query.Handler validates Content-Type routes by media type Accept-Query: … PostgreSQL application/sql SELECT * FROM users QUERY + body read-only query 200 OK + JSON results Click a query type to animate
WASM DB Integration Demo
Click a query type above to run the demo...
Backend integration patterns
// PostgreSQL: accept application/sql body, execute as read-only query
handler := query.NewHandler(func(req *query.Request) ([]byte, string, error) {
  sql := string(req.Body)

  // Safety: only SELECT statements allowed (parse + verify before exec)
  if !isSafeSelect(sql) {
    return nil, "", &query.QueryError{Status: 422, Message: "only SELECT queries allowed"}
  }

  // Execute in a read-only transaction to enforce safety semantics
  rows, err := db.QueryContext(ctx, "BEGIN READ ONLY; "+sql+"; COMMIT")
  if err != nil {
    return nil, "", &query.QueryError{Status: 422, Message: err.Error()}
  }
  defer rows.Close()

  result := scanRows(rows)   // → []map[string]interface{}
  out, _ := json.Marshal(result)
  return out, "application/json", nil
}, "application/sql")

// Content-Location lets client bookmark results:
// Content-Location: /queries/results/sha256-abcdef
// GraphQL: accept application/graphql body
import "github.com/graphql-go/graphql"

handler := query.NewHandler(func(req *query.Request) ([]byte, string, error) {
  // Accept both pure GraphQL body and JSON-wrapped {query, variables}
  var gqlQuery string
  var variables map[string]interface{}

  if strings.HasPrefix(req.ContentType, "application/graphql") {
    gqlQuery = string(req.Body)
  } else {
    var envelope struct { Query string; Variables map[string]interface{} }
    json.Unmarshal(req.Body, &envelope)
    gqlQuery = envelope.Query
    variables = envelope.Variables
  }

  result := graphql.Do(graphql.Params{Schema: schema, RequestString: gqlQuery, VariableValues: variables})
  out, _ := json.Marshal(result)
  return out, "application/json", nil
}, "application/graphql", "application/json")
from fastapi import FastAPI, Request, Response
from sqlalchemy import text
import json

app = FastAPI()

@app.api_route("/data", methods=["QUERY"])
async def sql_query(request: Request):
    ct = request.headers.get("content-type", "")

    if "application/sql" not in ct:
        return Response(status_code=415,
                        headers={"Accept-Query": "application/sql, application/json"})

    sql = (await request.body()).decode()

    # Enforce read-only: parse AST, reject non-SELECT
    if not sql.strip().upper().startswith("SELECT"):
        return Response(status_code=422,
                        content='{"error":"Only SELECT allowed"}')

    with db.connect() as conn:
        result = conn.execute(text(sql))
        rows = [dict(r) for r in result]

    return {"results": rows, "total": len(rows)}
const express = require('express');
const { MongoClient } = require('mongodb');

app.all('/documents', async (req, res) => {
  if (req.method !== 'QUERY') return res.status(405).send();

  const filter  = req.body?.filter  || {};
  const project = req.body?.project || {};
  const sort    = req.body?.sort    || {};
  const limit   = Math.min(req.body?.limit || 100, 1000);

  // MongoDB filter expression is the QUERY body — no separate query param
  const results = await collection
    .find(filter, { projection: project })
    .sort(sort)
    .limit(limit)
    .toArray();

  // Return Content-Location for client-side bookmarking
  const queryHash = sha256(JSON.stringify(req.body));
  res
    .set('Content-Location', `/queries/${queryHash}`)
    .set('Cache-Control', 'max-age=300')
    .json({ results, total: results.length });
});

Live Playground

Send QUERY requests to the in-browser Go server (compiled to WASM). All processing happens locally — no network requests.

QUERY Request Builder
Enter a query body and click "Send QUERY"...

Standards Tracker

This project tracks RFC 10008 and related HTTP standards. A GitHub Actions workflow checks the IETF Datatracker daily and opens a GitHub issue on any update.

IETF Standard Lifecycle
Individual Draft
WG Draft
Last Call
IESG Review
Published RFC ← RFC 10008
PoC status legend
Implemented — has animated demo + Go code
Monitoring — tracked; not yet implemented
Excluded — out of scope; decision documented
Production readiness
Only Published RFCs should be used in production. WG Drafts and individual drafts are unstable — breaking changes are expected.
WG Draft caution
This PoC implements and monitors WG drafts for early adoption exploration. implemented_rev in the baseline locks the specific revision implemented.
Tracked standards

Last checked: 2026-08-02  ·  standards-baseline.json  ·  standards-tracker.yml

Standard WG / Publisher Lifecycle status PoC status Rev Affected files Impl. commit
RFC 10008 — HTTP QUERY Method IETF HTTP WG ● Published RFC Implemented RFC pkg/query, cmd/demo-wasm, demo/index.html initial
RFC 9110 — HTTP Semantics IETF HTTP WG ● Published RFC Implemented RFC pkg/query (method semantics, status codes, Allow header) initial
RFC 7234 — HTTP/1.1 Caching IETF HTTP WG ● Published RFC Implemented RFC pkg/cache (TTL, X-Cache, no-transform, body-keyed key) initial
RFC 8288 — Web Linking IETF HTTP WG ● Published RFC Monitoring RFC Content-Location / Location result URI semantics monitoring
RFC 7807 — Problem Details IETF HTTP WG ● Published RFC Monitoring RFC pkg/query error responses (could adopt application/problem+json) monitoring
Automated tracking
Daily cron
GitHub Actions runs scripts/check_standards.py at 09:00 UTC. Queries the IETF Datatracker API for each tracked standard and compares to standards-baseline.json.
RFC finality check
For Published RFCs, the tracker checks for errata or obsoleted-by relationships. An RFC being obsoleted is treated as a high-priority update.
Issue opened on change
A standards-update labelled issue lists the change, the PoC impact, affected files, and the due-diligence checklist from docs/standards-tracking.md.
Loading WASM...