Why GraphQL needed an HTTP specification
The GraphQL specification deliberately says nothing about transport. For years every server picked its own answers to the questions that matter at the HTTP layer: whether GET is allowed, what the request body looks like, which media type the response has, and above all which status code a failed query gets. The commonest answer to the last one was “always 200”, which left gateways, logs and monitoring unable to tell a broken query from a working one without parsing every body.
GraphQL over HTTP, maintained by the GraphQL Foundation’s working group, standardizes those answers. It is at Stage 2 (Draft) in the GraphQL Foundation’s process, which means it can still change before reaching the Accepted stage. It does not override the GraphQL specification; where they disagree, GraphQL wins.
The headline change is a new response media type, application/graphql-response+json. Because a response with that type is known to come from a GraphQL server, the server is free to use meaningful status codes without confusing clients, which read the outcome from the body. If you run a GraphQL server, adopt it: the rules are cheap to follow and fix real operational blind spots.
1. Request parameters
Every GraphQL-over-HTTP request carries the same four parameters, whatever the method:
| Parameter | Required | Type | Meaning |
|---|---|---|---|
query |
Yes | string | The GraphQL document source text |
operationName |
No | string | Which operation in the document to run |
variables |
No | map | Values for the operation’s variables |
extensions |
No | map | Reserved for implementers, for example persisted query hashes |
query is a misleading name: the document can contain mutations. A request with the right names but wrong types, or missing query, is not well-formed. A query that does not parse is still well-formed at this layer; it fails later, at parsing.
2. POST and GET
A server must accept POST with a UTF-8 JSON body using Content-Type: application/json, and may accept other methods, such as GET.
POST /graphql HTTP/1.1Host: api.example.comContent-Type: application/jsonAccept: application/graphql-response+json, application/json;q=0.9
{"query": "query($id: ID!) { user(id: $id) { name } }", "variables": {"id": "QVBJcy5ndXJ1"}}Clients must send Content-Type, and a server should reject a POST without one with a 4xx.
For GET, the parameters go in the query string, URL-encoded, with variables and extensions serialized as JSON strings:
GET /graphql?query=query(%24id%3A%20ID!)%7Buser(id%3A%24id)%7Bname%7D%7D&variables=%7B%22id%22%3A%22QVBJcy5ndXJ1%22%7D HTTP/1.1Host: api.example.comAccept: application/graphql-response+jsonGET must never execute a mutation. If the document and operationName select a mutation, the server must halt with a 4xx; 405 Method Not Allowed is recommended, and RFC 9110 then requires an Allow header. This is what keeps GET safe, and so what makes it cacheable.
Support GET if you want CDN or browser caching of queries, ideally with persisted queries so the URL stays short. Otherwise POST-only is fully conformant and simpler.
3. Media types and Accept
| Media type | Used for |
|---|---|
application/json |
Request bodies, and responses to legacy clients |
application/graphql-response+json |
Responses; every conforming server must support it |
A client must list application/graphql-response+json in Accept. When it does not know whether the server supports it, it should send:
Accept: application/graphql-response+json, application/json;q=0.9If a client accepts only application/json, which is how a legacy client looks, the server processes the request exactly as if it had asked for the new type, but labels any 2xx response Content-Type: application/json. Error responses keep the meaningful status code either way. If Accept names nothing the server supports and not application/json either, the server should respond 406 Not Acceptable, though it may ignore Accept and pick a type. See content negotiation for the general mechanics.
A server that does not support application/graphql-response+json at all is a legacy server and does not conform.
4. Status codes
With application/graphql-response+json, clients must treat the response as a well-formed GraphQL response regardless of status, and read success or failure from the body. The status code is for everything else on the path: gateways, logs, metrics and anomaly detection.
| GraphQL result | Status |
|---|---|
data present and not null, no errors |
200 (must be 2xx) |
data present (even null) and errors present |
294 recommended |
No data entry: a request error |
An appropriate 4xx or 5xx |
For request errors the specification recommends specific codes:
| Failure | Status |
|---|---|
| Request body is not valid JSON | 400 |
| GraphQL document does not parse | 400 |
| Not a well-formed GraphQL-over-HTTP request | 422 |
| Document fails validation | 422 |
| Operation cannot be determined unambiguously | 422 |
| Variables cannot be coerced | 422 |
Mutation via GET, or unsupported method |
405 |
Unsupported request Content-Type |
415 |
| Request body too large | 413 |
| URI too long | 414, and the client should switch to POST |
| Not permitted | 401 or 403 |
| Server unavailable, maintenance, load shedding | 503 |
294 Partial Success
294 is a status code the specification defines itself, not one registered with IANA. A result with both data and errors is neither a success nor a request failure, and none of 203, 206 or 207 fits. RFC 9110 requires clients that do not recognize a code to treat it as the x00 of its class, so an unaware client sees 200. Use 294 only with application/graphql-response+json, and check that your CDN, gateway and caches handle it the way you intend; 294 is not on the list of heuristically cacheable codes and fixed lists in infrastructure will not know it.
If an error prevents producing a GraphQL response at all, the server uses an ordinary 4xx or 5xx and must not label the body application/graphql-response+json.
5. Security notes
The specification calls out one cross-site risk specifically. A POST with application/json triggers a CORS preflight when sent cross-origin; multipart/form-data or application/x-www-form-urlencoded do not, which opens the endpoint to cross-site request forgery. If you accept those media types (for file uploads, typically), require a custom header that forces a preflight. See CORS.
Best Practices
- Return
application/graphql-response+jsonand real status codes. “Always200” hides failures from every tool that is not a GraphQL client. - Keep
POSTwithapplication/jsonas the baseline, and addGETonly for queries you want cached. - Reject mutations over
GETwith405and anAllow: POSTheader. - Do not turn partial results into
4xxor5xx. Some data came back; that is294.