GraphQL JSON: Response Format, Queries, Variables & Error Handling
GraphQL always returns JSON — every response has a top-level data object for successful results and an errors array when something goes wrong. Both can appear together in a partial-success response: some resolvers succeeded, others failed, and the client receives whatever data was available. A GraphQL response with one error still returns HTTP 200 (not 4xx/5xx) unless the entire request is malformed; the errors[0].locations field gives the line and column in the query, and errors[0].path gives the field path in the response — making GraphQL errors structurally different from REST API errors. This guide covers the GraphQL response JSON structure, the data/errors/extensions envelope, querying with fetch, handling partial errors, variables vs inline arguments, and comparing GraphQL JSON vs REST JSON. Use Jsonic to validate and pretty-print any GraphQL response JSON while debugging.
Working with a GraphQL response? Paste it into Jsonic to pretty-print and explore the nested data structure instantly.
Open JSON FormatterThe GraphQL JSON response envelope: data, errors, extensions
Every GraphQL response is a JSON object with up to 3 top-level keys. The data envelope is the foundation of the format — understanding its 3 parts is required before writing any GraphQL client code.
| Key | Type | When present | Description |
|---|---|---|---|
data | object | null | Execution occurred | Query result; null only if execution aborted entirely |
errors | array | At least 1 error | List of error objects; may coexist with data (partial success) |
extensions | object | Optional | Custom metadata: tracing, cache hints, request ID, cost info |
A fully successful response — no errors, all resolvers returned data — contains only data:
// Query
{
user(id: "1") {
id
name
email
}
}
// Response — HTTP 200
{
"data": {
"user": {
"id": "1",
"name": "Alice",
"email": "alice@example.com"
}
}
}A partial-success response — 1 resolver threw, the rest succeeded — contains both data and errors. The failed field is set to null in the data tree and described in the errors array:
// Response — HTTP 200 (partial success)
{
"data": {
"user": {
"id": "1",
"name": "Alice",
"email": null
}
},
"errors": [
{
"message": "Email address is not visible to this user",
"locations": [{ "line": 5, "column": 5 }],
"path": ["user", "email"],
"extensions": {
"code": "FORBIDDEN",
"http": { "status": 403 }
}
}
]
}The path array tells you exactly which field in the response tree failed:["user", "email"] means data.user.email. This is far more precise than a REST 403 response that tells you nothing about which field was blocked. If multiple resolvers fail in one request, the errors array contains 1 object per failure — all failures are reported in a single response.
Sending a GraphQL query with fetch and JSON variables
GraphQL queries are sent as HTTP POST requests with Content-Type: application/json. The request body is a JSON object with 3 possible fields: query (the GraphQL document string, required), variables (a JSON object mapping variable names to values, optional), and operationName (a string to select one named operation when the document contains multiple, optional). Always use variables instead of string interpolation — it prevents injection, enables server-side query caching, and is required for persisted queries.
// ✅ Correct — use variables
async function fetchUser(userId) {
const response = await fetch('https://api.example.com/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token,
},
body: JSON.stringify({
query: `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
posts(first: 5) {
title
publishedAt
}
}
}
`,
variables: { id: userId },
}),
})
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`)
}
const json = await response.json()
// Always check for errors even on HTTP 200
if (json.errors) {
console.error('GraphQL errors:', json.errors)
}
return json.data
}
// ❌ Unsafe — never interpolate user input into query strings
// query: `{ user(id: "${userId}") { name } }`The fetch API returns a promise that resolves on any HTTP response including 4xx/5xx — the response.ok check catches transport-level failures. After that, you must separately inspect json.errors for GraphQL-level errors. Two separate checks are always required. For TypeScript, define a generic wrapper:
interface GraphQLResponse<T> {
data?: T
errors?: Array<{
message: string
locations?: Array<{ line: number; column: number }>
path?: Array<string | number>
extensions?: Record<string, unknown>
}>
extensions?: Record<string, unknown>
}
async function gql<T>(
query: string,
variables?: Record<string, unknown>
): Promise<GraphQLResponse<T>> {
const res = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<GraphQLResponse<T>>
}GraphQL error object structure and extensions
Each object in the errors array follows the same structure defined by the GraphQL specification. Only message is required; the other 3 fields are optional but widely supported by all major servers (Apollo Server, Hasura, GraphQL Yoga, Strawberry). Understanding all 4 fields is essential for building reliable error-handling logic.
| Field | Type | Required | Description |
|---|---|---|---|
message | string | Yes | Human-readable description of the error |
locations | array of {line, column} | No | Position(s) in the query document where the error originated |
path | array of string | number | No | Path from response root to the failed field; numbers for list indices |
extensions | object | No | Arbitrary server-defined metadata — error codes, HTTP status hints, stack traces |
// Full error object example (Apollo Server format)
{
"errors": [
{
"message": "Cannot read property 'name' of null",
"locations": [
{ "line": 4, "column": 7 }
],
"path": ["posts", 2, "author", "name"],
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exception": {
"stacktrace": [
"TypeError: Cannot read property 'name' of null",
" at AuthorResolver.name (src/resolvers/author.ts:42:18)"
]
}
}
}
]
}The path array ["posts", 2, "author", "name"] tells you the error is at data.posts[2].author.name — the 3rd post's author's name field failed. This path precision is one of GraphQL's biggest advantages over REST: you know exactly which field in a complex nested response caused the problem, enabling targeted null-safety checks in your UI rather than wholesale error states.
The extensions.code convention (used by Apollo Server and many others) provides machine-readable error codes. Common values include UNAUTHENTICATED (401 equivalent), FORBIDDEN (403), NOT_FOUND (404), BAD_USER_INPUT (422), and INTERNAL_SERVER_ERROR (500). Build your error-handling logic against extensions.code, not message — messages change, codes are stable.
GraphQL mutations with JSON variables
Mutations are sent identically to queries — the only difference is the mutation keyword in the GraphQL document. A mutation body uses the same 3-field JSON structure: query (the mutation string), variables, and optionally operationName. Pass complex input objects as variables — never as inline literal arguments in the query string.
// Mutation body sent as JSON
{
"query": "mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { id title slug } }",
"variables": {
"input": {
"title": "GraphQL JSON Guide",
"body": "Everything you need to know about GraphQL responses.",
"tags": ["graphql", "json", "api"],
"published": true
}
}
}
// fetch call
const response = await fetch('https://api.example.com/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
slug
}
}
`,
variables: {
input: {
title: 'GraphQL JSON Guide',
body: 'Everything you need to know.',
tags: ['graphql', 'json', 'api'],
published: true,
},
},
}),
})
// Successful mutation response
// {
// "data": {
// "createPost": {
// "id": "post_42",
// "title": "GraphQL JSON Guide",
// "slug": "graphql-json-guide"
// }
// }
// }For file uploads, the standard JSON approach does not work. Use the GraphQL multipart request spec (Jayden Seric's spec, implemented by Apollo Server and GraphQL Yoga). The request uses Content-Type: multipart/form-data with 3 form fields: operations (the query/variables JSON with null placeholders for files), map (a JSON object mapping file field names to variable paths), and the actual file fields. The JSON:API specification takes a different approach — compare both when choosing your API format.
// File upload — multipart/form-data (not application/json)
const formData = new FormData()
formData.append('operations', JSON.stringify({
query: 'mutation UploadAvatar($file: Upload!) { uploadAvatar(file: $file) { url } }',
variables: { file: null }, // null placeholder
}))
formData.append('map', JSON.stringify({ '0': ['variables.file'] }))
formData.append('0', fileInput.files[0]) // actual file
await fetch('/graphql', { method: 'POST', body: formData })
// No Content-Type header — browser sets it with boundary automaticallyHandling partial errors in GraphQL JSON responses
Partial success — a response with both data and errors — is the most important GraphQL concept to handle correctly. It occurs in at least 3 scenarios: a resolver threw an exception, a nullable field's downstream service was unavailable, or a permission check rejected a specific field for this user. The correct approach is to handle both simultaneously: show partial data where available and display targeted error messages for the failed fields.
async function loadUserProfile(userId) {
const { data, errors } = await gql(`
query Profile($id: ID!) {
user(id: $id) {
name # required — if null, show error state
email # nullable — show "hidden" if null
recentOrders { # nullable list — show empty state if null
id
total
}
}
}
`, { id: userId })
// Index errors by path for targeted error display
const errorsByPath = {}
for (const err of (errors ?? [])) {
const pathKey = (err.path ?? []).join('.')
errorsByPath[pathKey] = err
}
return {
name: data?.user?.name ?? 'Unknown',
email: data?.user?.email ?? null,
emailError: errorsByPath['user.email']?.extensions?.code,
orders: data?.user?.recentOrders ?? [],
ordersError: errorsByPath['user.recentOrders']?.message,
}
}
// Example output for a partial response:
// {
// name: "Alice",
// email: null,
// emailError: "FORBIDDEN",
// orders: [],
// ordersError: "Orders service unavailable"
// }3 rules for partial-error handling: (1) never throw immediately when you see errors — the data object may have useful partial results; (2) use errors[n].path to map each error to the specific field it describes; (3) check extensions.code (not message) for programmatic error classification. Libraries like Apollo Client surface { data, error } simultaneously from every query hook precisely to support this pattern. Validate the response JSON structure using Jsonic's formatter when debugging unexpected null fields.
GraphQL JSON vs REST JSON: 5 structural differences
GraphQL and REST both use JSON, but they differ in 5 structural ways that affect every client you write. Understanding these differences prevents the 2 most common bugs: assuming HTTP 200 means success (it doesn't in GraphQL) and assuming the response shape matches REST conventions (it doesn't).
| Aspect | REST JSON | GraphQL JSON |
|---|---|---|
| Response envelope | Direct resource: {"id":1,"name":"Alice"} | Always wrapped: {"data":{"user":{...}}} |
| Error signaling | HTTP 4xx/5xx status codes | HTTP 200 + errors array in body |
| Response shape | Fixed — server decides all fields | Exact — only fields requested in query |
| Multiple resources | 1 request per endpoint / N+1 problem | 1 request for any depth of related data |
| Partial failure | All-or-nothing per request | Partial: some fields succeed, others null with errors |
| Error detail | No field-level location info | locations (query position) + path (response field) |
The most impactful difference for client code is error signaling. A REST client can branch on response.status:
// REST error handling — HTTP status is authoritative
const res = await fetch('/api/users/1')
if (res.status === 404) return showNotFound()
if (res.status === 403) return showForbidden()
if (!res.ok) throw new Error('Request failed')
const user = await res.json()
// GraphQL error handling — must inspect body, HTTP status alone is not enough
const res = await fetch('/graphql', { method: 'POST', ... })
if (!res.ok) throw new Error(`Transport error ${res.status}`)
const { data, errors } = await res.json()
if (errors?.some(e => e.extensions?.code === 'NOT_FOUND')) return showNotFound()
if (errors?.some(e => e.extensions?.code === 'FORBIDDEN')) return showForbidden()
// data may still be partially useful even if errors existFor a deeper comparison of JSON API formats, see JSON:API specification which defines a third convention with its own envelope structure. The REST API JSON response guide covers REST conventions in detail.
Persisted queries, the extensions field, and advanced patterns
The extensions field in GraphQL JSON responses is an open-ended object for server-defined metadata. 3 common uses are: query tracing (Apollo Tracing adds resolver timing breakdowns), cache hints (Hasura includes cache-control directives), and request cost analysis (rate-limiting by query complexity score). Clients can use this data for performance monitoring without changing the core data/errors structure.
// Response with extensions (Apollo Server tracing)
{
"data": { "user": { "id": "1", "name": "Alice" } },
"extensions": {
"tracing": {
"version": 1,
"startTime": "2026-05-12T10:00:00.000Z",
"endTime": "2026-05-12T10:00:00.042Z",
"duration": 42000000,
"execution": {
"resolvers": [
{ "path": ["user"], "duration": 38000000, "startOffset": 1000000 },
{ "path": ["user", "name"], "duration": 500000, "startOffset": 39000000 }
]
}
},
"requestId": "req_abc123"
}
}Persisted queries reduce request payload by 90%+ by replacing the full query string with a pre-registered hash. Instead of sending a 2 KB query string on every request, the client sends a 64-byte SHA-256 hash. The server looks up the registered query and executes it. The request body changes from {"query": "...(2KB)...", "variables": {...}} to {"extensions": {"persistedQuery": {"version": 1, "sha256Hash": "abc..."}}, "variables": {...}}. Apollo Client supports Automatic Persisted Queries (APQ) out of the box — on the first request the query string is sent along with its hash; on subsequent requests only the hash is sent.
// Automatic Persisted Query (APQ) — first request (hash miss)
{
"query": "query GetUser($id: ID!) { user(id: $id) { name } }",
"variables": { "id": "1" },
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9aeace8516eee7f8d8af8028"
}
}
}
// APQ — subsequent requests (hash hit, no query string needed)
{
"variables": { "id": "1" },
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9aeace8516eee7f8d8af8028"
}
}
}For projects that use JSON.stringify to serialize request bodies manually, be aware that JSON.stringify does not guarantee key ordering in the generated query hash — always compute the hash from the normalized query string, not from the stringified variables object.
Frequently asked questions
What does a GraphQL JSON response look like?
A GraphQL JSON response always has a top-level object with up to 3 keys: data (an object or null, present when execution occurred), errors (an array of error objects, present only if at least 1 error occurred), and extensions (an optional object for custom metadata like tracing or cache info). A successful response with no errors contains only data: {"data": {"user": {"id": "1", "name": "Alice"}}}. A response with a field error contains both: {"data": {"user": {"id": "1", "name": null}}, "errors": [{"message": "Name not found", "locations": [{"line": 3, "column": 5}], "path": ["user", "name"]}]}. Both data and errors can appear together in a single response — this is a deliberate part of the GraphQL spec called partial success. The HTTP status code is 200 in both cases unless the request itself is malformed. To explore real GraphQL response JSON, paste it into Jsonic's formatter to navigate the nested structure.
Why does GraphQL return HTTP 200 even when there are errors?
GraphQL uses HTTP 200 for field-level errors because the HTTP request itself succeeded — the server received a valid query, executed it, and returned a well-formed JSON response. Field errors (a resolver threw, a permission check failed, a downstream service was unavailable) are application-layer events, not transport-layer failures. The GraphQL spec deliberately separates these 2 layers: HTTP status codes describe transport success or failure; the errors array describes application-level problems. This means you cannot use HTTP status codes alone to detect GraphQL errors — you must always inspect the JSON response body for an errors key. The only times GraphQL legitimately returns non-200 are: 400 Bad Request for a completely unparseable query string, 405 Method Not Allowed if you send a GET for a mutation, and 500 for catastrophic server failures before execution begins. Compare this to REST API JSON responses where HTTP status codes are the primary error mechanism.
How do I send a GraphQL query with variables using fetch?
Send a POST request with Content-Type: application/json and a JSON body containing 3 fields: query (the GraphQL query string), variables (an object mapping variable names to values), and optionally operationName (a string to select one operation from a document with multiple). Example: fetch("/graphql", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: "query GetUser($id: ID!) { user(id: $id) { name email } }", variables: { id: "1" } }) }). Using variables instead of string interpolation is critical for security — it prevents GraphQL injection attacks and enables the server to cache parsed query documents. Never build a query string by concatenating user input: query: `{ user(id: "${userId}") { name } }` is unsafe and bypasses query caching. See the full fetch JSON guide for handling network errors and timeouts around the fetch call itself.
How do I handle partial errors in a GraphQL JSON response?
A partial success response contains both a data object and an errors array. The data object has null values at the paths that failed, and the errors array explains why. To handle this correctly: (1) check for the errors key first — if it exists, log or display each error.message and classify by extensions.code; (2) check that data is not null — if the root query itself failed, data will be null and all fields are unavailable; (3) for each field you need, check whether it is null before using it, since a field-level error sets that field to null in the response. Index the errors array by path.join('.') for efficient per-field error lookup. Many GraphQL clients (Apollo Client, urql) surface both data and error to your component simultaneously, allowing you to show partial results alongside an error notice rather than a full error state.
What is the difference between GraphQL JSON and REST JSON responses?
REST JSON and GraphQL JSON differ in 5 key ways. (1) Envelope: REST responses return the resource directly ({"id":1,"name":"Alice"}), while GraphQL always wraps data in {"data": {...}}. (2) Error signaling: REST uses HTTP status codes (404, 422, 500) to signal errors; GraphQL uses HTTP 200 plus an errors array in the body for field-level errors. (3) Shape: REST returns a fixed server-determined shape; GraphQL returns exactly the fields requested in the query — no more, no less. (4) Multiple resources: REST requires 1 request per endpoint; a single GraphQL query can fetch deeply nested, related data in 1 round trip. (5) Partial failure: REST is all-or-nothing per request; GraphQL can return partial data with per-field errors. For error handling this means: REST clients can branch on response.status; GraphQL clients must always parse the response body and check for errors regardless of status code. The JSON:API specification defines a third REST-based convention with its own envelope.
How do I send a GraphQL mutation with JSON variables?
A mutation is sent identically to a query — POST to the GraphQL endpoint with Content-Type: application/json and a JSON body. Use the mutation keyword and declare input variables with their types. Example body: {"query": "mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id name email } }", "variables": {"input": {"name": "Bob", "email": "bob@example.com"}}}. The response follows the same data/errors envelope. For file uploads, the standard JSON POST approach does not work — you need the GraphQL multipart request spec, which uses Content-Type: multipart/form-data and a specific operations/map field structure. For all non-file mutations, always use variables over string interpolation to prevent injection and enable server-side query caching. Use JSON.stringify to serialize the full request body from the query string and variables object.
Ready to inspect GraphQL JSON responses?
Paste any GraphQL response into Jsonic's JSON Formatter to pretty-print and navigate the nested data/errors/extensions structure. Need to compare 2 responses? Use the JSON Diff tool.