技術約14分で読めます

HTTP QUERYメソッドはGETとPOSTの隙間を埋めるのか

いけさん目次

2026年6月、HTTPに新しいメソッドQUERYが加わった。
RFC 10008: The HTTP QUERY MethodとしてProposed Standardになり、IANAのHTTP Method RegistryにもSafe: yesIdempotent: yesで登録されている。

QUERYは「リクエストボディを持てる、読み取り専用の問い合わせ」を表すメソッドだ。
GETのように安全(safe)かつ冪等(idempotent。同じリクエストを何度送っても結果が変わらない性質)で、POSTのようにボディを送れる。
いままでPOST /searchPOST /graphqlで書いてきた「本文付きの読み取り問い合わせ」に、HTTPとしての名前が付いたことになる。

GETで足りなくなるところ

小さい検索条件ならGETでいい。

GET /feed?q=foo&limit=10&sort=-published HTTP/1.1
Host: example.org

安全で冪等、キャッシュしやすく、URLとして条件ごと共有できる。

問題は、問い合わせ条件が大きくなったときだ。
検索UIのフィルタ、ネストしたAND/OR、全文検索、ソート、ページング、集計条件が重なると、URLはすぐ長くなる。
RFC 10008も、URIに載せるデータが大きすぎる場合の問題として、経路上のサイズ制限が事前に分かりにくいこと、URIエンコードのオーバーヘッド、URIがログやブックマークに残りやすいことを挙げている。

GETにボディを付ける手もあるが、RFC 9110ではGETリクエストの本文に一般に定義された意味がない。
クライアントは、オリジンサーバーが明示的に対応している場合を除き、GET本文を生成すべきではないともされている。
途中にプロキシ、CDN、ロードバランサ、フレームワークが入ると、本文を捨てる、拒否する、想定外に扱う、という実装差が出る。

POSTだと中間層に意味が伝わらない

現実のAPIでは、こういう形がよく使われる。

POST /feed HTTP/1.1
Host: example.org
Content-Type: application/json

{
  "q": "foo",
  "limit": 10,
  "sort": "-published"
}

ボディにJSONを置けて、URL長の制限も気にしなくていい。
ただしHTTPとしてのPOSTは「対象リソースに、リクエスト本文をそのリソース固有の意味で処理させる」メソッドで、安全でも冪等でもない。

アプリケーション側で「このPOST /searchは読み取り専用」と決めることはできる。
でも、それはそのAPIを知っている人間とサーバーだけの約束だ。
キャッシュ、プロキシ、リトライ制御、CORS、APIゲートウェイ、WAFといった中間のHTTPコンポーネントは、レスポンスが返る前にリクエストの扱いを決める。
POST /search200 OKが返ってきても、それが読み取り専用の検索だったのか、サーバー状態を変更する処理だったのかは、HTTPレイヤーからは区別が付かない。

ここでQUERYが入る。

QUERY /feed HTTP/1.1
Host: example.org
Content-Type: application/json

{
  "q": "foo",
  "limit": 10,
  "sort": "-published"
}

RFC 10008は、QUERYを安全かつ冪等なメソッドとして定義している。
リクエスト本文とContent-Typeなどのメタデータが問い合わせ内容を定義し、対象URIが問い合わせのスコープを決める。
POSTと同じように本文を送れるが、「これは読み取り専用で、再試行できる問い合わせだ」という意味がメソッドから読み取れる。

QUERYが定義するもの

QUERYが定義するのはHTTPレベルの意味だけで、問い合わせ言語には踏み込まない。
SQLを標準化するわけでも、GraphQLを置き換えるわけでも、検索APIのスキーマを決めるわけでもない。

メソッドボディ安全冪等主な使いどころ
GET一般に意味なしyesyesURIで表せるリソース取得
POSTありnono作成、送信、実行、リソース固有処理
PUTありnoyes対象リソースの置き換え
DELETE一般には不要noyes対象リソースの削除
OPTIONS一般には不要yesyes通信オプションや対応メソッドの確認
QUERYありyesyes本文で条件を送る読み取り問い合わせ

RFC 10008では、QUERYリクエストにContent-Typeを必須としている。
メディアタイプがない、または本文と一致しない場合は4xxで失敗させる。
対応していないメディアタイプなら415 Unsupported Media Type、構文としては正しいが問い合わせ内容を処理できないなら422 Unprocessable Content、クライアントが受け取れるレスポンス形式にできないなら406 Not Acceptableが候補になる。

サーバーはAccept-Queryレスポンスヘッダーで、どの問い合わせ形式を受けられるかを示せる。
RFCの例ではapplication/jsonpathapplication/sqlが出てくる。

Accept-Query: "application/jsonpath", application/sql;charset="UTF-8"

対象URIは問い合わせのスコープを決める。
QUERY /ordersなら注文集合に対する問い合わせ、QUERY /graphqlならGraphQLエンドポイントに対する問い合わせ、という見方になる。
URIと本文の組み合わせで問い合わせの意味が決まる。

キャッシュキーはURLだけでは作れない

QUERYで変わるのは、実装コードの見た目より中間層の挙動だ。

QUERYは冪等なので、接続失敗などで同じリクエストを再送しやすい。
POSTでもアプリケーションが冪等キーを用意すれば再試行できるが、HTTPメソッドだけでは判断できない。
QUERYなら、少なくともリクエストの意味としては再送してよいと分かる。

キャッシュはもう少し複雑になる。
RFC 10008はQUERYレスポンスをキャッシュ可能としているが、キャッシュキーには対象URIだけでなくリクエスト本文と関連メタデータを含めることを求めている。
GET /feed?q=fooならURLがほぼそのままキャッシュキーになるのに対し、QUERY /feedではボディ全体とContent-Typeまで見てキーを作ることになる。

QUERY /orders HTTP/1.1
Host: api.example.com
Content-Type: application/json

{ "status": "paid", "limit": 50 }
QUERY /orders HTTP/1.1
Host: api.example.com
Content-Type: application/json

{ "status": "cancelled", "limit": 50 }

この2つは同じURLに見えるが、キャッシュキーとしては別物でなければならない。
一番単純なのは、キャッシュ内部で「URI + 正規化したボディのハッシュ」をキーにする形だ。

method: QUERY
target: https://api.example.com/orders
content-type: application/json
accept: application/json
body-digest: sha256(canonical-json(body))
auth-scope: tenant:123:user:456

このキーはCDN、リバースプロキシ、APIゲートウェイ、アプリ内キャッシュが内部的に使うもので、人間が見るURLではない。
JSONなら空白やキー順序を揃えてからハッシュ化したいが、その正規化がサーバー側の解釈とズレると、別の問い合わせに同じレスポンスを返してしまう。
RFC 10008も、QUERYのキャッシュはGETより複雑だと明示している。

QUERYの条件はURLコピペで共有できない

GET /orders?status=paid&limit=50なら、URLそのものが問い合わせ条件を含むので、そのままブラウザ、Slack、Issue、ブックマークへ貼れる。
QUERY /ordersでは条件はボディにあり、URLだけコピーしても条件は消える。

RFC 10008は、この穴をLocationContent-Locationで埋める道を用意している。
最初はQUERYで複雑な条件を送り、サーバーが問い合わせや結果にURIを与え、次回以降はそのURIへGETする形だ。

QUERY /reports HTTP/1.1
Host: api.example.com
Content-Type: application/json

{
  "from": "2026-01-01",
  "to": "2026-06-30",
  "groupBy": ["region", "plan"],
  "metrics": ["revenue", "churn"]
}
HTTP/1.1 200 OK
Content-Type: application/json
Location: /reports/queries/8f3a...
Content-Location: /reports/results/7b91...
Cache-Control: max-age=300

Locationは「この問い合わせ条件そのものを再実行できるURI」、Content-Locationは「今回の問い合わせ結果を取得できるURI」として使い分けられる。

URIの種類意味
問い合わせURI/reports/queries/8f3a...同じ条件で再実行するためのURI
結果URI/reports/results/7b91...その時点の結果、または一定期間保存された結果を取るURI

問い合わせURIの裏側には「このIDはこの検索条件」という保存済み検索があり、データが更新されれば結果も変わる。
結果URIはスナップショットを指すので結果は固定になるが、保存期限や権限管理という別の設計が付いてくる。
実装としては、問い合わせ本文を正規化してハッシュ化し、/reports/queries/{hash}のようなURIを返す形が考えやすい。

RESTとGraphQLでの位置づけ

REST APIでは、QUERYはRESTの統一インターフェース(uniform interface)に追加される読み取り用メソッドとして捉えられる。
小さい取得と単純な検索は今まで通りGETでいい。

GET /orders?status=paid&limit=50 HTTP/1.1
Host: api.example.com

QUERYが効くのは、条件がURLに押し込むには大きく、でも意味としては読み取り専用のときだ。

QUERY /orders HTTP/1.1
Host: api.example.com
Content-Type: application/json

{
  "filter": {
    "and": [
      { "status": ["paid", "refunded"] },
      { "createdAt": { "gte": "2026-01-01", "lt": "2026-07-01" } },
      { "customer": { "segment": ["enterprise", "education"] } }
    ]
  },
  "sort": [{ "field": "createdAt", "direction": "desc" }],
  "limit": 100
}

対象URIは問い合わせ対象のコレクションにする。
/orders/queryのような動詞っぽいサブリソースを作るより、QUERY /ordersのほうがHTTPメソッドに意味を寄せられる。
既存のREST APIを全部置き換える話ではなく、URLで十分に表せる取得はGETのまま、更新・作成・削除・ジョブ起動はPOSTPUTPATCHDELETEのままだ。

GraphQLでは事情が少し違う。
現在のGraphQL over HTTPのドラフト仕様では、サーバーはPOSTを受け付けなければならず、GETは受け付けてもよい。
また、mutationをGETで実行してはいけない。
実際のリクエストは、読み取りのqueryでもこういうPOSTになる。

POST /graphql HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/graphql-response+json

{
  "query": "query ($id: ID!) { user(id: $id) { name } }",
  "variables": { "id": "123" }
}

このPOSTは、GraphQLの中身としては読み取り専用のqueryかもしれない。
でもHTTPレイヤーから見るとPOSTで、中間層はGraphQL本文を理解しない限りqueryとmutationを区別できない。

QUERYをGraphQLに使うなら、こういう形が考えられる。

QUERY /graphql HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/graphql-response+json

{
  "query": "query ($id: ID!) { user(id: $id) { name } }",
  "variables": { "id": "123" }
}

GraphQLのqueryであることと、HTTPのQUERYであることが揃い、読み取り専用・再試行可能・キャッシュ可能という情報をHTTPメソッドとして外に出せる。
mutationは副作用を持つのでPOSTに残る。
ただし、GraphQL over HTTPの現行ドラフトがQUERYを採用しているわけではないので、実際に使うにはGraphQLサーバー、クライアント、CDN、フレームワーク側の対応を待つことになる。

観点REST APIGraphQL型APIHTTP QUERY
主役リソースURIとHTTPメソッドスキーマとクエリ言語HTTPメソッドの意味
典型URL/orders, /orders/123/graphql任意の対象URI
読み取り主にGETPOSTまたはGETQUERY
複雑な条件URL queryかPOST /searchになりがちボディにGraphQL文を置くボディに問い合わせ内容を置く
副作用POSTPUTPATCHDELETEmutationQUERYでは扱わない

想定される利用場面

一番分かりやすいのは、検索とフィルタだ。
ECサイトの商品検索、管理画面の注文検索、ログ検索、監査イベント検索、ユーザー一覧の絞り込み。
条件が数個ならGETで足りるが、条件ビルダーがあってネストしたAND/ORや配列条件を持つならQUERYが候補になる。

次に、レポートや分析API。
期間、ディメンション、メトリクス、フィルタをJSONで送るが、処理としては読み取り専用というケースだ。
今はPOST /reports/runのように書きがちだが、本当に結果取得だけならQUERY /reportsのほうが意味に合う。

GraphQLやJSONPathのような問い合わせ言語も相性がいい。
RFC 10008のAccept-Query例にもapplication/jsonpathが出てくる。
SQLライクな読み取りクエリ、ドキュメントDBの検索DSL、OData風の複雑なフィルタも、本文に問い合わせ言語を載せるという点で使い方は変わらない。

FetchやAxiosから見るとただのmethod指定

クライアントコードでは、fetchmethod"QUERY"を指定して本文を付ける。

const response = await fetch("https://api.example.com/orders", {
  method: "QUERY",
  headers: {
    "Content-Type": "application/json",
    "Accept": "application/json",
  },
  body: JSON.stringify({
    filter: {
      status: ["paid", "refunded"],
      createdAt: {
        gte: "2026-01-01",
        lt: "2026-07-01",
      },
    },
    sort: [{ field: "createdAt", direction: "desc" }],
    limit: 100,
  }),
});

if (!response.ok) {
  throw new Error(`QUERY failed: ${response.status}`);
}

const orders = await response.json();

見た目はPOSTとほとんど同じで、違うのはHTTPメソッドとして「これは読み取り問い合わせです」と宣言しているところだけ。
ブラウザから別オリジンへ送るなら、QUERYはCORSのプリフライトリクエスト(本リクエスト前にブラウザが送るOPTIONSでの事前確認)の対象になる。
サーバー側がAccess-Control-Allow-MethodsQUERYを入れていないと、アプリコードに届く前にブラウザが止める。

Axiosでも考え方は同じで、method"QUERY"を渡す。

import axios from "axios";

const { data } = await axios({
  url: "https://api.example.com/orders",
  method: "QUERY",
  headers: {
    "Content-Type": "application/json",
    "Accept": "application/json",
  },
  data: {
    filter: {
      status: ["paid", "refunded"],
      createdAt: {
        gte: "2026-01-01",
        lt: "2026-07-01",
      },
    },
    sort: [{ field: "createdAt", direction: "desc" }],
    limit: 100,
  },
});

ライブラリのコードが書けることと、経路上で通ることは別の話だ。
フロントエンドコード、ブラウザ、プロキシ、CDN、WAF、APIゲートウェイ、バックエンドフレームワークのどこかが未知のメソッドを拒否すると失敗する。
実験するなら、まずcurl -X QUERYでオリジンまで通るか確認し、そのあとブラウザのプリフライトを確認するのがよさそうだ。

curl -i -X QUERY "https://api.example.com/orders" \
  -H "Content-Type: application/json" \
  -d '{"filter":{"status":["paid"]},"limit":10}'

受け側はREQUEST_METHODと本文を見る

PHPで素朴に受けるなら、$_SERVER['REQUEST_METHOD']QUERYかどうかを確認して、php://inputから本文を読み取る流れになる。
$_POSTは「POSTメソッドなら何でも入る配列」ではなく、PHPがフォームエンコードされた本文をパースした結果だ。
application/jsonの本文は、メソッドがPOSTでもQUERYでも基本的にphp://inputから生の本文を読んでjson_decode()する。

<?php
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: https://app.example.com');
header('Access-Control-Allow-Methods: GET, POST, QUERY, OPTIONS');
header('Access-Control-Allow-Headers: content-type');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204);
    exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'QUERY') {
    header('Allow: GET, POST, QUERY, OPTIONS');
    http_response_code(405);
    echo json_encode(['error' => 'Method Not Allowed']);
    exit;
}

$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (stripos($contentType, 'application/json') !== 0) {
    http_response_code(415);
    echo json_encode(['error' => 'Unsupported Media Type']);
    exit;
}

$rawBody = file_get_contents('php://input');
$query = json_decode($rawBody, true);

if (!is_array($query)) {
    http_response_code(400);
    echo json_encode(['error' => 'Invalid JSON']);
    exit;
}

// ここでDB検索などを行う。QUERYなので状態変更はしない。
$result = [
    'method' => 'QUERY',
    'filter' => $query['filter'] ?? null,
    'items' => [],
];

echo json_encode($result, JSON_UNESCAPED_UNICODE);

Node.jsなら、フレームワークを使わない素のhttpでも同じことができる。
やることは、メソッド確認、CORSプリフライト応答、Content-Type確認、本文のJSONパース、読み取り処理、レスポンスの順だ。

import http from "node:http";

const server = http.createServer(async (req, res) => {
  res.setHeader("Access-Control-Allow-Origin", "https://app.example.com");
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, QUERY, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "content-type");

  if (req.method === "OPTIONS") {
    res.writeHead(204);
    res.end();
    return;
  }

  if (req.method !== "QUERY") {
    res.writeHead(405, {
      "Content-Type": "application/json; charset=utf-8",
      "Allow": "GET, POST, QUERY, OPTIONS",
    });
    res.end(JSON.stringify({ error: "Method Not Allowed" }));
    return;
  }

  const contentType = req.headers["content-type"] ?? "";
  if (!contentType.startsWith("application/json")) {
    res.writeHead(415, { "Content-Type": "application/json; charset=utf-8" });
    res.end(JSON.stringify({ error: "Unsupported Media Type" }));
    return;
  }

  let rawBody = "";
  for await (const chunk of req) {
    rawBody += chunk;
  }

  let query;
  try {
    query = JSON.parse(rawBody);
  } catch {
    res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
    res.end(JSON.stringify({ error: "Invalid JSON" }));
    return;
  }

  // ここでDB検索などを行う。QUERYなので状態変更はしない。
  const result = {
    method: "QUERY",
    filter: query.filter ?? null,
    items: [],
  };

  res.writeHead(200, {
    "Content-Type": "application/json; charset=utf-8",
    "Cache-Control": "max-age=60",
  });
  res.end(JSON.stringify(result));
});

server.listen(3000);

Expressなどのフレームワークでも、ルーターが任意メソッドを拾えるなら同じ発想になる。
ただし、ミドルウェアやルーターが未知のHTTPメソッドを落とす設定になっていることがあるので、app.all()で拾えるか、独自メソッドを明示登録できるかを先に確認する。

使うときの注意

QUERYは標準化されたが、すぐに全環境で自然に通るとは限らない。
新しいHTTPメソッドは、ロードバランサ、CDN、WAF、APIゲートウェイ、フレームワーク、ルーター、CORS設定、監視ツールのどこかで許可リストに弾かれることがある。

ブラウザからクロスオリジンで使う場合、QUERYはCORSのプリフライト対象になる。
OPTIONSへの応答でAccess-Control-Allow-Methods: QUERYを返さないと通らない。

OPTIONS /feed HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: QUERY
Access-Control-Request-Headers: content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, QUERY
Access-Control-Allow-Headers: content-type

キャッシュを効かせるなら、ボディを含むキャッシュキーの扱いを先に確認する。
CDNがQUERYを理解していなければ単にオリジンへ素通しするだけだし、本文を見ずにURLだけでキャッシュする実装が挟まると、異なる問い合わせに同じレスポンスを返してしまう。

サーバー側のContent-Typeチェックについて、RFC 10008は、Content-Typeがない、または本文と矛盾している場合に失敗させることを求めている。
application/jsonとして受けるならJSONとしてパースし、独自DSLならそのメディアタイプを決めてAccept-Queryで宣言する。
ここを曖昧にすると、POST /search時代の暗黙ルールを新しいメソッドに移しただけになる。

URIはアクセスログ、アクセス解析、ブラウザ履歴、Referer、ブックマークに残りやすい。
QUERYなら、URLに載せたくない検索条件を本文へ移せる。
ただし、APIゲートウェイやアプリケーションログがリクエストボディを保存していれば、本文に移しても結局は記録に残る。

QUERYを使うべきでない場面もある。
状態を変える処理、ジョブを起動する処理、課金や送信を発生させる処理、監査上「1回だけ実行」が重要な処理はQUERYではない。
読み取りでも、URLで十分に表せて共有したいものはGETのままでいい。

参考