Serving GraphQL over HTTP
|
This section documents the current GraphQL specification (October 2021), plus the working draft for
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as GraphQL tooling iterates quickly. |
GraphQL defines a query language and a type system, but says nothing by itself about how a request reaches a server over the network — that’s the job of the separate GraphQL-over-HTTP specification. This page covers the single-endpoint convention, the two HTTP methods a server typically accepts, the request parameters and response media types the wire format uses, status-code rules, and the surrounding concerns — batching, caching, CORS, CSRF, and tooling — that come with running GraphQL on top of HTTP.
The single endpoint
Unlike REST’s many URL-addressed resources, a GraphQL server conventionally exposes exactly one endpoint — almost always /graphql — that accepts every operation, whatever the query, mutation, or subscription asks
for:
POST /graphql HTTP/1.1
Host: api.example.org
Content-Type: application/json
Accept: application/json
{"query":"query { book(id: \"42\") { title } }"}
The operation itself, not the URL, is what determines what data comes back. There is no equivalent of a REST URL path per resource to version or document separately — schema evolution (see Schema Design) happens by adding types and fields to the one schema behind the one endpoint. See graphql.org/learn/serving-over-http for the canonical introduction to this convention.
GET vs. POST
A server built for GraphQL almost always accepts POST as its primary method, and may additionally accept
GET for simple, side-effect-free reads:
| Method | Typical use |
|---|---|
|
The default for every operation type, including mutations and subscriptions' initial handshake. Parameters travel in a JSON request body, with no practical length limit and no risk of a query being cached or logged as part of a URL. |
|
Optional, and conventionally restricted to queries only — never mutations, since a |
A GET request encodes the same parameters as query-string keys, URL-encoded, with variables and
extensions serialized as JSON strings within the URL:
GET /graphql?query=query%20GetBook(%24id%3A%20ID!)%20%7B%20book(id%3A%20%24id)%20%7B%20title%20%7D%20%7D&variables=%7B%22id%22%3A%2242%22%7D HTTP/1.1
Host: api.example.org
Accept: application/json
Equivalently, with curl doing the URL-encoding:
curl -G https://api.example.org/graphql \
--data-urlencode 'query=query GetBook($id: ID!) { book(id: $id) { title } }' \
--data-urlencode 'variables={"id":"42"}'
The GraphQL-over-HTTP specification requires a compliant server to support POST; GET support, and which
operation types it’s offered for, is a server’s own choice. See
the GraphQL-over-HTTP specification for the normative
method requirements.
Request parameters
Whichever method carries it, a GraphQL-over-HTTP request recognizes four parameters, escaped here as
\{ query, variables, operationName, extensions }:
| Parameter | Meaning |
|---|---|
|
Required. The GraphQL document’s source text — one or more operation and fragment definitions. |
|
Optional. A JSON object mapping variable names (without the leading |
|
Required only when |
|
Optional. A JSON object for client-supplied, server-defined metadata outside the spec’s own
fields — most commonly a persisted-query hash (see Automatic Persisted Queries below), never business data
that belongs in |
A request naming more than one operation without operationName — or naming one that doesn’t exist in the
document — is a request error the server rejects before execution begins:
query GetBook($id: ID!) {
book(id: $id) { title }
}
query GetAuthor($id: ID!) {
author(id: $id) { name }
}
{
"query": "query GetBook($id: ID!) { book(id: $id) { title } } query GetAuthor($id: ID!) { author(id: $id) { name } }",
"operationName": "GetAuthor",
"variables": { "id": "7" }
}
See graphql.org/learn/serving-over-http for the parameter list, and the GraphQL-over-HTTP specification for the exact encoding rules for each transport (JSON body vs. URL query string).
Response media types
A server’s Content-Type response header, together with what the client sent as Accept, determines which of
two conforming media types the body is serialized as:
| Media type | Behavior |
|---|---|
|
The long-standing, widely deployed default. A server serving this type conventionally
answers every well-formed GraphQL request with |
|
Defined by the GraphQL-over-HTTP specification specifically so that
transport-level failures can be told apart from execution-level ones using ordinary HTTP status codes, without
a client needing to parse the body first. A client opts in by sending |
POST /graphql HTTP/1.1
Content-Type: application/json
Accept: application/graphql-response+json
{"query":"{ book(id: \"42\") { title } }"}
HTTP/1.1 200 OK
Content-Type: application/graphql-response+json
{"data":{"book":{"title":"The Left Hand of Darkness"}}}
Response and Error Handling owns the JSON body’s own
\{ data, errors, extensions } shape once it’s on the wire; this page’s scope stops at how that body is framed
and status-coded at the HTTP layer. See
the GraphQL-over-HTTP
specification’s media type section for the full negotiation rules.
Status-code rules
The two media types carry different status-code expectations:
-
Under
application/json, the prevailing convention isHTTP 200for every request that reached the GraphQL layer at all, including one whosedataisnulland whoseerrorsarray is full — a4xx/5xxis reserved for failures below GraphQL entirely (malformed JSON, an unroutable path, an unsupported method). -
Under
application/graphql-response+json, the specification defines a stricter mapping: a request that is well-formed JSON but fails GraphQL-level request validation (unparsablequery, a missing required parameter) can legitimately return400 Bad Request; one that violates the server’s own operational limits can return other4xxcodes; and a request that executes, however manyerrorsit produces, still returns200, matching theapplication/jsonbehavior for that one case.
curl -s -o /dev/null -w '%{http_code}\n' https://api.example.org/graphql \
-H 'Content-Type: application/json' \
-H 'Accept: application/graphql-response+json' \
-d '{"query": "{ not a valid query"}'
# 400
See the GraphQL-over-HTTP specification’s status code section for the complete, normative table of when each code applies.
Request batching
Some servers (and most client libraries, transparently) support sending several operations in one HTTP round trip as a JSON array of request objects instead of a single object — reducing per-request overhead when a client needs to fire off multiple independent queries at once:
[
{ "query": "query { book(id: \"1\") { title } }" },
{ "query": "query { book(id: \"2\") { title } }" }
]
A batched response mirrors the request shape — a JSON array of individual \{ data, errors } results in the
same order. Batching is not part of the core GraphQL-over-HTTP specification; it’s a widely adopted, de facto
convention that specific server and client implementations opt into, so whether — and how — a given server
supports it is implementation-specific rather than guaranteed. Performance
and N+1 covers batching data-loading inside a single execution (via a request-scoped DataLoader), which is
a different mechanism from batching multiple top-level HTTP requests together.
GET cacheability
Because HTTP caches key on method and URL, a GET request with its parameters in the query string is
transparently cacheable by a browser, CDN, or reverse proxy the same way a REST GET is — something a POST
request, whose body a cache does not inspect, cannot get for free:
GET /graphql?query=%7B%20book(id%3A%20%2242%22)%20%7B%20title%20%7D%20%7D HTTP/1.1
Accept: application/json
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
Content-Type: application/json
{"data":{"book":{"title":"The Left Hand of Darkness"}}}
This is one of the main reasons a server offers GET at all despite POST being the default: pairing a GET
query with an ordinary Cache-Control header lets a CDN serve repeat requests for the same query and variables
without reaching the origin server. Caching covers HTTP-level caching (and
its alternatives — normalized client-side caches, persisted-query-keyed caching) in depth.
CORS
A GraphQL endpoint called from a browser-based client on a different origin than the server needs the same Cross-Origin Resource Sharing configuration any other cross-origin HTTP API needs — GraphQL introduces nothing special here beyond what its own request shape implies for preflight:
OPTIONS /graphql HTTP/1.1
Origin: https://app.example.org
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.org
Access-Control-Allow-Methods: POST, GET
Access-Control-Allow-Headers: content-type
Because a JSON POST with Content-Type: application/json is not a CORS "simple request," a browser sends the
preflight OPTIONS shown above before the actual request — the same rule that applies to any JSON REST API, not
something GraphQL-specific.
CSRF prevention
A GET-based GraphQL query, and a POST sent with a form-encoded (rather than JSON) content type, can both be
triggered cross-site by a browser without a preflight check, the same way a classic HTML form submission can — which makes CSRF a live concern for any mutation-capable GraphQL endpoint that accepts either shape. The
standard mitigations from ordinary web security apply directly: requiring a custom header (most simply,
requiring Content-Type: application/json, which forces the preflight above and blocks a plain form
submission), checking Origin/Referer on state-changing requests, or requiring a CSRF token for any POST
that isn’t application/json. Several server frameworks — see
Spring Boot: Transports, Security &
Testing for one concrete example — reject non-JSON POST bodies to a GraphQL endpoint by default specifically
to close off this surface, and disabling that protection to accept form-encoded requests should be a deliberate,
narrow decision rather than a default.
GraphiQL and endpoint tooling
Getting Started introduces GraphiQL, Apollo Sandbox, and GraphQL
Playground as schema-exploration tools; at the HTTP layer, what makes any of them work against a given server is
simply that server answering GET requests with Accept: text/html (or a dedicated path) by serving the
IDE’s own HTML/JS bundle instead of a GraphQL JSON response, alongside the ordinary POST handler the IDE calls
underneath. A production deployment commonly disables this HTML surface (or gates it behind authentication)
while leaving the JSON endpoint itself open, since serving an interactive query editor to the public internet
widens the attack surface described in
Security and Demand Control.
Automatic Persisted Queries and trusted documents
A full query document can be large, and sending its complete text on every request wastes bandwidth for a client that issues the same operation repeatedly. Automatic Persisted Queries (APQ) and the related trusted documents pattern both address this by having the client send a short hash instead of the query text on repeat calls:
{
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"
}
}
}
The first time the server sees a given hash it doesn’t recognize, it replies with a PersistedQueryNotFound
error; the client then retries once, this time sending both query and the same extensions.persistedQuery
hash so the server can register the mapping for every subsequent call:
{
"query": "query GetBook($id: ID!) { book(id: $id) { title } }",
"variables": { "id": "42" },
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"
}
}
}
Trusted documents takes this further as a security posture rather than just a bandwidth optimization: the
server pre-registers an allowlist of hashes at deploy time (generated from the client’s own build) and refuses
any query whose hash isn’t already known, rather than accepting arbitrary query text from any caller at all — closing off the query-shape-based attacks covered in
Security and Demand Control. Both patterns also shrink a
GET request’s URL enough to make caching (above) practical for queries whose full text would otherwise exceed
a comfortable URL length. See graphql.org/learn/serving-over-http
for the APQ/trusted-documents overview.
Related pages
-
Getting Started — the request/response envelope and the interactive tools that call this transport.
-
Response and Error Handling — the JSON body’s own
data/errors/extensionsshape once it’s framed by this page’s media types. -
Caching — HTTP-level and client-level caching strategies built on
GETcacheability and persisted queries. -
Security and Demand Control — trusted documents, query allowlisting, and other protections for a public endpoint.
-
Spring Boot: Transports, Security & Testing — one framework’s concrete CSRF and endpoint configuration.
-
Tips: When to Use GraphQL or a Typical REST API — how this single-endpoint,
HTTP 200-for-errors transport model weighs against a typical REST API.