GraphQL JSON Wire Format: Query Variables, Mutations & Persisted Queries
Last updated:
Every GraphQL request is a JSON POST with three fields: query (the operation string), variables (an object), and operationName — the server responds with {"data": {...}, "errors": [...]}, never a custom HTTP status code for GraphQL errors. GraphQL errors always return HTTP 200 with a non-null errors array alongside partial data — check response.errors before reading response.data. The variables object is a JSON-serialized map; scalar coercion (Int, Float, Boolean, String, ID) happens server-side, so the client always sends plain JSON values.
This guide covers the JSON wire format for queries, mutations, and subscriptions; variable typing and nullability; the @skip and @include directives; inline fragments and named fragments; persisted queries for payload reduction; and the JSON custom scalar pattern. For foundational GraphQL JSON concepts, see our GraphQL JSON basics guide.
The GraphQL JSON Wire Format: query, variables, operationName
A GraphQL request is a standard HTTP POST carrying a JSON body with up to three fields. query contains the operation document as a string. variables is a JSON object mapping variable names (without the $ prefix) to their JSON values. operationName is a string that selects which named operation to run when the document contains multiple. The server always replies with a JSON object: data (the resolved result tree) and optionally errors (an array of error objects). HTTP status codes signal only transport failures — a 400 means malformed JSON or unparseable query syntax; all GraphQL-layer errors arrive inside the 200 response body.
// Minimal GraphQL request — no variables, shorthand query
fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: '{ currentUser { id name email } }',
}),
});
// Full request with variables and operationName
fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
query: `
query GetUserProfile($userId: ID!, $includeOrders: Boolean = false) {
user(id: $userId) {
id
name
email
orders @include(if: $includeOrders) {
id
total
}
}
}
`,
variables: {
userId: '42',
includeOrders: true,
},
operationName: 'GetUserProfile',
}),
});
// Server response shape — HTTP 200 always
// Success:
// { "data": { "user": { "id": "42", "name": "Alice", "email": "alice@example.com", "orders": [...] } } }
// Partial result with error:
// {
// "data": { "user": { "id": "42", "name": "Alice", "orders": null } },
// "errors": [{ "message": "Not authorized to view orders", "path": ["user","orders"],
// "locations": [{ "line": 7, "column": 11 }] }]
// }
// Transport error — malformed JSON body or unparseable query
// HTTP 400 { "errors": [{ "message": "Syntax Error: Expected Name, found <EOF>." }] }The operationName field is optional when the document contains exactly one operation, but including it is a best practice — it improves server-side logging, tracing, and persisted query lookup. Never construct the query string by interpolating variable values directly — always use the variables object to prevent injection vulnerabilities and to enable query plan caching on the server. The Content-Type: application/json header is required; some servers also accept application/graphql (query string body, no JSON wrapper) but this format does not support variables.
Variables: Types, Nullability, and Default Values
GraphQL variables are declared in the operation signature with a $ prefix and a type annotation. The type annotation controls nullability and coercion: ID! is non-null (required), Int is nullable (optional), [String!]! is a non-null list of non-null strings. The client always sends plain JSON values in the variables object — the server performs scalar coercion. Default values are declared in the operation signature with = value and are used when the variable is omitted or null in the variables object.
// Schema type definitions (server-side, for reference)
// type Query {
// users(filter: UserFilter, limit: Int = 20, offset: Int = 0): [User!]!
// user(id: ID!): User
// }
// input UserFilter { role: Role, active: Boolean }
// enum Role { ADMIN USER GUEST }
// Operation with multiple variable types
const SEARCH_USERS = `
query SearchUsers(
$filter: UserFilter # nullable input type
$limit: Int = 20 # nullable Int with default
$offset: Int = 0 # nullable Int with default
$ids: [ID!] # nullable list of non-null IDs
) {
users(filter: $filter, limit: $limit, offset: $offset) {
id
name
role
}
}
`;
// Variables object — JSON types map to GraphQL scalar types:
// GraphQL String ← JSON string "Alice"
// GraphQL Int ← JSON number 42 (must be integer, no decimal)
// GraphQL Float ← JSON number 3.14
// GraphQL Boolean ← JSON boolean true / false
// GraphQL ID ← JSON string "42" OR JSON number 42 (coerced to string)
// GraphQL Enum ← JSON string "ADMIN" (must match enum value name)
// GraphQL InputType ← JSON object { "role": "ADMIN", "active": true }
const variables = {
filter: { role: 'ADMIN', active: true }, // maps to UserFilter input type
limit: 10, // JSON number → GraphQL Int
// offset omitted — server uses default value 0
ids: ['1', '2', '3'], // JSON array of strings → [ID!]
};
// Nullability rules:
// Variable declared as ID! (non-null) — must provide a value; omitting it causes a validation error
// Variable declared as Int (nullable) — omit or send null to use the default or null
// Variable declared as [String!]! (non-null list of non-null strings):
// - The list itself cannot be null (must provide [])
// - Each element cannot be null (["a", null, "b"] is rejected)
// Sending null explicitly vs omitting:
// $status: String — both null and omitted mean "no value"
// $status: String = "active" — omitting uses default "active"; sending null overrides to null
const withExplicitNull = { filter: null }; // filter is explicitly null
const withOmitted = {}; // filter omitted — same as null for nullable vars
// @skip and @include directives control field selection with a Boolean variable
const CONDITIONAL_QUERY = `
query GetDashboard($showOrders: Boolean!, $skipAnalytics: Boolean!) {
currentUser {
id
name
orders @include(if: $showOrders) { id total }
analytics @skip(if: $skipAnalytics) { pageViews conversions }
}
}
`;The @include(if: Boolean!) directive includes a field only when the variable is true; @skip(if: Boolean!) excludes a field when the variable is true. Both directives accept only non-null Boolean variables (Boolean!) — passing a nullable Boolean causes a validation error. Use @include and @skip to conditionally fetch expensive or permission-gated fields without changing the query document, which preserves query plan caching. For enum variables, the JSON string value must exactly match the enum value name defined in the schema — case-sensitive, no transformation.
Parsing GraphQL Responses: data, errors, and Partial Results
A GraphQL response always contains at least one of data or errors. Both can be present simultaneously — partial results are the normal case when one resolver fails but siblings succeed. The errors array entries each have a message string, an optional locations array (line and column in the query document), an optional path array (the field path that produced the error), and an optional extensions object for server-specific metadata like error codes.
// TypeScript types for GraphQL response envelope
interface GraphQLError {
message: string;
locations?: Array<{ line: number; column: number }>;
path?: Array<string | number>;
extensions?: Record<string, unknown>; // server-specific: { code: 'UNAUTHORIZED', ... }
}
interface GraphQLResponse<T> {
data?: T;
errors?: GraphQLError[];
}
// Robust response handler — never assume success without checking errors
async function graphqlFetch<T>(
query: string,
variables?: Record<string, unknown>,
operationName?: string,
): Promise<GraphQLResponse<T>> {
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables, operationName }),
});
// Transport-level failures — server could not process the request at all
if (!response.ok && response.status !== 200) {
throw new Error(`GraphQL transport error: HTTP ${response.status}`);
}
const json: GraphQLResponse<T> = await response.json();
return json; // always return the full envelope; let callers decide error handling
}
// Usage — check errors before reading data
const result = await graphqlFetch<{ user: { id: string; name: string } }>(
'query GetUser($id: ID!) { user(id: $id) { id name } }',
{ id: '42' },
'GetUser',
);
// Pattern 1: throw on any error
if (result.errors?.length) {
const codes = result.errors.map(e => e.extensions?.['code']).join(', ');
throw new Error(`GraphQL errors [${codes}]: ${result.errors[0].message}`);
}
// Pattern 2: accept partial data, log errors
if (result.errors) {
result.errors.forEach(err => {
console.error(`[GraphQL] ${err.path?.join('.')} — ${err.message}`);
});
}
const user = result.data?.user; // may be null if that field errored
// Error extensions pattern — server sends structured error metadata
// { "errors": [{ "message": "Not authenticated",
// "extensions": { "code": "UNAUTHENTICATED", "statusCode": 401 } }] }
const authError = result.errors?.find(
e => e.extensions?.['code'] === 'UNAUTHENTICATED',
);
if (authError) redirectToLogin();When a non-null field (field: User!) errors, GraphQL propagates the null upward through the response tree until it finds a nullable parent — this is the null-bubbling behavior. A single non-null field failure can null out its entire parent object, which is why a partial data object and a non-empty errors array can appear together. The extensions object on each error is the standard place to carry machine-readable error codes — use it for client-side error classification instead of parsing the message string.
Mutations with JSON Input Types
Mutations follow the same JSON wire format as queries — the only difference on the wire is the mutation keyword in the operation string. The server executes mutation root fields sequentially (guaranteed order), unlike query fields which may run in parallel. Input types (input keyword in the schema) define the shape of complex mutation arguments — they accept the same JSON scalar types as query variables but cannot include interface or union types, and they cannot have circular references.
// Schema input types (server-side reference)
// input CreatePostInput {
// title: String!
// body: String!
// tags: [String!]! = []
// publishedAt: String # ISO 8601 date string — no Date scalar assumed
// metadata: JSON # custom JSON scalar for arbitrary key-value
// }
// Mutation request body — same three-field JSON structure as queries
const CREATE_POST = `
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
slug
publishedAt
author { id name }
}
}
`;
fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: CREATE_POST,
variables: {
input: {
title: 'GraphQL JSON Wire Format',
body: 'Every GraphQL request is a JSON POST...',
tags: ['graphql', 'json', 'api'],
publishedAt: '2026-05-20T00:00:00Z', // JSON string; schema treats as String
metadata: { featured: true, weight: 10 }, // JSON scalar — any valid JSON
},
},
operationName: 'CreatePost',
}),
});
// Multiple mutations in one request — guaranteed sequential execution
const BATCH_MUTATIONS = `
mutation BatchUpdate($userId: ID!, $postId: ID!) {
updateUser(id: $userId, input: { lastActiveAt: "2026-05-20T00:00:00Z" }) {
id updatedAt
}
incrementPostViews(id: $postId) { # executes AFTER updateUser completes
id views
}
}
`;
// Mutation response — same data/errors envelope
// {
// "data": {
// "updateUser": { "id": "1", "updatedAt": "2026-05-20T10:00:00Z" },
// "incrementPostViews": { "id": "99", "views": 1042 }
// }
// }
// File uploads — multipart form, not JSON
// For file uploads, use the GraphQL multipart request spec:
// Content-Type: multipart/form-data
// operations: JSON string {"query": "mutation Upload($file: Upload!) {...}", "variables": {"file": null}}
// map: JSON string {"0": ["variables.file"]}
// 0: (the actual file binary)Sequential execution of mutation root fields makes it safe to run multiple state-changing operations in one network round trip — the second mutation sees the committed state of the first. However, mutations in a batch still run in a single database transaction only if the server explicitly wraps them; most GraphQL servers run each mutation root field in its own transaction. File uploads break the JSON-only wire format — the GraphQL multipart request specification defines a multipart/form-data encoding where the operations field carries the JSON mutation body and binary files are attached as separate form parts.
Fragments: Inline Fragments and Named Fragments as JSON
Fragments are reusable field selections defined in the query document string — they do not affect the JSON wire format of the request body. Named fragments are defined with fragment Name on Type { ... } and spread with ...Name. Inline fragments are used directly in the selection set, particularly for interface and union type discrimination using __typename. The server expands all fragments and returns a single flat JSON data object — clients never see fragment boundaries in the response.
// Named fragment — defined once, reused across operations in the same document
const DOCUMENT = `
fragment UserFields on User {
id
name
email
avatarUrl
createdAt
}
fragment OrderSummary on Order {
id
total
status
createdAt
}
query GetUserWithOrders($userId: ID!) {
user(id: $userId) {
...UserFields # spread named fragment
orders(last: 5) {
...OrderSummary
}
}
}
query GetCurrentUser {
currentUser {
...UserFields # same fragment reused
}
}
`;
// Request body selects which operation to run
fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: DOCUMENT,
variables: { userId: '42' },
operationName: 'GetUserWithOrders', // required when document has multiple operations
}),
});
// Inline fragments for union and interface types
// Schema: union SearchResult = User | Post | Tag
const SEARCH_QUERY = `
query Search($term: String!) {
search(term: $term) {
__typename # discriminator field — always request it for unions
... on User {
id
name
email
}
... on Post {
id
title
publishedAt
}
... on Tag {
id
label
postCount
}
}
}
`;
// Response — __typename tells the client which concrete type each item is
// {
// "data": {
// "search": [
// { "__typename": "User", "id": "1", "name": "Alice", "email": "alice@example.com" },
// { "__typename": "Post", "id": "99", "title": "GraphQL JSON", "publishedAt": "2026-05-20" },
// { "__typename": "Tag", "id": "5", "label": "graphql", "postCount": 42 }
// ]
// }
// }
// Client-side discrimination
function renderSearchResult(item: { __typename: string }) {
switch (item.__typename) {
case 'User': return renderUser(item as UserResult);
case 'Post': return renderPost(item as PostResult);
case 'Tag': return renderTag(item as TagResult);
}
}Always request __typename on union and interface fields — it is a meta-field that GraphQL servers return as a JSON string containing the concrete type name, and it is the only reliable way to discriminate union members in the response JSON. Code generators like GraphQL Code Generator use __typename to produce discriminated union TypeScript types automatically. Named fragments must be defined in the same document string as the operations that spread them — you cannot reference a fragment defined in a different request.
Persisted Queries: Reducing JSON Payload Size
Persisted queries (Automatic Persisted Queries, APQ) replace the full query string with a SHA-256 hash, dramatically reducing request payload size for large operation documents. The protocol is a two-phase handshake: on a cache miss, the server returns a PersistedQueryNotFound error and the client retries with the full query string; on subsequent requests, only the hash is sent. APQ is most impactful for mobile clients where query documents can be several kilobytes.
// Phase 1: send only the hash — no query string
// Request body:
{
"variables": { "userId": "42" },
"operationName": "GetUserProfile",
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"
}
}
}
// Cache hit response — full data returned:
// { "data": { "user": { "id": "42", "name": "Alice" } } }
// Cache miss response — server does not recognise the hash:
// { "errors": [{ "message": "PersistedQueryNotFound",
// "extensions": { "code": "PERSISTED_QUERY_NOT_FOUND" } }] }
// Phase 2 (after cache miss): resend with full query string AND hash
{
"query": "query GetUserProfile($userId: ID!) { user(id: $userId) { id name email } }",
"variables": { "userId": "42" },
"operationName": "GetUserProfile",
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"
}
}
}
// APQ client implementation (simplified)
import { createHash } from 'crypto';
async function apqFetch<T>(query: string, variables?: Record<string, unknown>) {
const hash = createHash('sha256').update(query).digest('hex');
const extensions = { persistedQuery: { version: 1, sha256Hash: hash } };
// Phase 1: hash only
let response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variables, extensions }),
});
let json = await response.json();
const isNotFound = json.errors?.some(
(e: { extensions?: { code?: string } }) =>
e.extensions?.code === 'PERSISTED_QUERY_NOT_FOUND',
);
if (isNotFound) {
// Phase 2: include full query
response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables, extensions }),
});
json = await response.json();
}
return json as { data?: T; errors?: unknown[] };
}
// GET requests for persisted queries — enables HTTP caching via CDN
// GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"ecf4..."}}
// &variables={"userId":"42"}
// &operationName=GetUserProfile
// Only works after the query has been stored server-side; GET cannot send a bodyAfter the server stores a persisted query, you can use GET requests instead of POST — enabling CDN and browser caching for read-only queries (cache-control headers apply). Apollo Studio and Relay compiler both generate SHA-256 hashes at build time for all compiled operations, so production clients never send raw query strings. The extensions field in the request body is the standard extension point defined by the GraphQL over HTTP specification — it carries arbitrary JSON and is forwarded to the server alongside the standard fields.
JSON Custom Scalar: Arbitrary JSON Values in GraphQL
The JSON custom scalar allows a GraphQL field to accept or return any valid JSON value — object, array, string, number, boolean, or null — without defining a concrete schema type for it. On the wire, a JSON scalar field is embedded directly as a native JSON value in the response data object. For input, the JSON value is passed directly in the variables object without any special encoding. This flexibility comes at the cost of type safety — code generators cannot produce TypeScript types for JSON scalar fields, and introspection tools cannot document their structure.
// Schema definition (server-side)
// scalar JSON
//
// type Product {
// id: ID!
// name: String!
// metadata: JSON # any JSON value — object, array, primitive
// attributes: JSON # e.g. { "color": "red", "sizes": ["S","M","L"] }
// }
//
// type Mutation {
// updateProductMetadata(id: ID!, metadata: JSON!): Product!
// }
// Query — JSON scalar field returns as native JSON in the response data
const PRODUCT_QUERY = `
query GetProduct($id: ID!) {
product(id: $id) {
id
name
metadata # JSON scalar — no sub-selection braces allowed
attributes # JSON scalar
}
}
`;
// Response — JSON scalars are embedded as native JSON values:
// {
// "data": {
// "product": {
// "id": "1",
// "name": "Widget",
// "metadata": { "featured": true, "weight": 0.5, "tags": ["sale","new"] },
// "attributes": { "color": "red", "sizes": ["S","M","L"], "inStock": true }
// }
// }
// }
// Mutation — pass JSON value directly in the variables object
fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
mutation UpdateMetadata($id: ID!, $metadata: JSON!) {
updateProductMetadata(id: $id, metadata: $metadata) {
id metadata
}
}
`,
variables: {
id: '1',
metadata: { // passed as a nested JSON object — no string encoding needed
featured: true,
weight: 0.5,
tags: ['sale', 'new'],
config: null, // null is a valid JSON value
},
},
operationName: 'UpdateMetadata',
}),
});
// Server-side JSON scalar implementation (graphql-js)
import { GraphQLScalarType, Kind } from 'graphql';
export const JSONScalar = new GraphQLScalarType({
name: 'JSON',
description: 'Arbitrary JSON value — object, array, string, number, boolean, or null',
serialize: (value) => value, // output: pass through as-is
parseValue: (value) => value, // input via variables: pass through as-is
parseLiteral: (ast) => { // input via inline literals (rare)
if (ast.kind === Kind.STRING) return JSON.parse(ast.value);
if (ast.kind === Kind.OBJECT) return parseObjectLiteral(ast);
return null;
},
});
// TypeScript — JSON scalar fields require explicit typing since codegen produces 'unknown'
type ProductMetadata = {
featured?: boolean;
weight?: number;
tags?: string[];
};
// Cast the unknown scalar to your known shape
const metadata = product.metadata as ProductMetadata;The graphql-scalars npm package provides production-ready implementations of JSON and dozens of other custom scalars (DateTime, EmailAddress, URL, UUID, etc.) with proper validation logic. Prefer typed schema fields over JSON scalars whenever possible — a typed field like metadata: ProductMetadata gives clients introspection, TypeScript types from code generation, and field-level validation. Reserve JSON scalars for genuinely dynamic data: user-defined configuration objects, plugin extension points, and external API payloads whose shape you do not control.
Key Terms
- operation document
- The GraphQL query string sent in the
queryfield of the JSON request body. An operation document can contain one or more named operations (query, mutation, or subscription) plus fragment definitions. When the document contains multiple operations, theoperationNamefield in the request body selects which one to execute. The server parses the document string, validates it against the schema, and executes the selected operation. Operation documents are the primary target for query plan caching, persisted query hashing, and static analysis by tools like GraphQL Code Generator. - variables object
- A JSON object in the request body whose keys are GraphQL variable names (without the
$prefix) and whose values are plain JSON values. The server validates each variable value against the declared type, performs scalar coercion (e.g., JSON string"42"to GraphQL Int42), and substitutes the values into the operation at execution time. Always use the variables object instead of interpolating values into the query string — it prevents injection vulnerabilities, enables query plan caching (the query string remains constant across different variable values), and is required for persisted queries. - persisted query
- A technique where the client sends a SHA-256 hash of the query document instead of the full query string, reducing request payload size. The hash is sent in
extensions.persistedQuery.sha256Hash. On a cache miss the server returns aPersistedQueryNotFounderror and the client retries with the full query string. After the server stores the query, subsequent requests need only the hash. Also called Automatic Persisted Queries (APQ). Enables GET-based GraphQL requests for CDN caching of read-only queries. Apollo Client and Apollo Server implement APQ out of the box; other clients and servers provide it as a plugin or middleware. - JSON scalar
- A GraphQL custom scalar type that accepts any valid JSON value — object, array, string, number, boolean, or null — without further type constraints. Defined with
scalar JSONin the schema and implemented server-side withserialize,parseValue, andparseLiteralfunctions that pass values through without transformation. On the wire, JSON scalar fields are embedded as native JSON values in the response data object, and JSON scalar variables are passed as nested JSON in the variables object. JSON scalars bypass GraphQL's type system, preventing code generation tools from producing typed client code — use them only when the data structure is genuinely dynamic or externally defined. - partial result
- A GraphQL response that contains both a non-null
dataobject and a non-emptyerrorsarray, indicating that some fields resolved successfully and others failed. Partial results are the normal case in GraphQL — a single field resolver failure does not abort the entire operation. Thepatharray on each error object identifies which field in the response tree produced the error. Null-bubbling can expand partial results: if a non-null field fails, its null propagates upward through non-null parent fields until a nullable parent absorbs it, potentially nulling out a larger subtree than expected. - input type
- A GraphQL type defined with the
inputkeyword that describes the shape of complex mutation or query arguments. Input types are structurally identical to object types but are used exclusively for input — they cannot have resolver functions, cannot implement interfaces, cannot include union members, and cannot reference output-only types. On the JSON wire, input type values are plain JSON objects whose keys match the input type field names and whose values are JSON values of the appropriate scalar or nested input type. Input types make mutation arguments evolvable without breaking changes — add new optional fields without changing existing client calls.
FAQ
What JSON format does GraphQL use for requests?
A GraphQL request is a JSON POST body with up to three fields: query (the operation document string), variables (a JSON object mapping variable names to values), and operationName (a string selecting which named operation to run). The minimal valid request is {"query": "{ currentUser { id } }"}. Variables and operationName are optional but recommended for parameterised operations. The Content-Type header must be application/json. The server always responds with a JSON object containing a data key and optionally an errors array — HTTP status codes reflect only transport failures, not GraphQL-layer errors.
How do I send variables in a GraphQL query?
Declare variables in the operation signature with a $ prefix and a type: query GetUser($id: ID!) { user(id: $id) { name } }. Then send them in the variables field of the JSON body: {"query": "...", "variables": {"id": "123"}}. Variable keys are names without the $ prefix. Values are plain JSON — the server performs scalar coercion server-side. Never interpolate variable values into the query string; always use the variables object to prevent injection and to enable query plan caching.
Why does GraphQL always return HTTP 200?
GraphQL uses HTTP as a transport layer only. The specification says a well-formed request the server understood should return HTTP 200 even if GraphQL execution produced errors. Field-level errors (resolver threw, authorization failed on a specific field) are reported in the errors array alongside partial data. HTTP non-200 codes are reserved for transport-level failures: 400 for malformed JSON or unparseable query syntax, 401/403 for authentication failures before GraphQL execution reaches, and 500 for catastrophic server failures. Always check response.errors in your client code — do not rely on HTTP status alone.
How do I handle GraphQL errors in JSON responses?
Always check response.errors before reading response.data. Both can be present simultaneously — partial results are common. Each error entry has a message string, optional locations (line/column in the query), optional path (the field path that failed), and optional extensions for machine-readable codes like { "code": "UNAUTHENTICATED" }. Pattern: const { data, errors } = await res.json(); if (errors) { handleErrors(errors); } if (data) { processData(data); }. Do not short-circuit on errors alone — data may still contain useful partial results.
What is a persisted query in GraphQL?
A persisted query sends a SHA-256 hash of the query document instead of the full query string, reducing payload size. The hash goes in extensions.persistedQuery.sha256Hash. On a cache miss the server returns PersistedQueryNotFound and the client retries with the full query. After the server stores it, only the hash is needed. Also called APQ (Automatic Persisted Queries). After storage, GET requests become possible — enabling CDN caching for read-only queries. Apollo Client and Apollo Server implement APQ out of the box.
How do I use a JSON scalar type in GraphQL?
Define scalar JSON in the schema and implement it server-side with pass-through serialize/parseValue functions. On the wire, a JSON scalar field is embedded as a native JSON value in the response — no special encoding. For input, pass the JSON value directly in the variables object: {"variables": {"metadata": {"key": "value", "tags": [1,2,3]}}}. The graphql-scalarsnpm package provides a production-ready implementation. Use JSON scalars sparingly — they bypass GraphQL's type system and make code generation and introspection much less useful.
How do I send a GraphQL mutation with JSON variables?
A mutation uses the same JSON wire format as a query — only the keyword changes. Send a POST with: {"query": "mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id name } }", "variables": {"input": {"name": "Alice", "email": "alice@example.com"}}, "operationName": "CreateUser"}. The input variable maps to an Input Type defined in the schema. Input type values are plain JSON objects in the variables object. Always use Input Types for mutation arguments rather than listing individual scalar arguments — it makes the schema easier to evolve.
What is the difference between query and mutation in GraphQL JSON?
On the JSON wire level, queries and mutations are identical — both are POST requests with the same three-field body (query, variables, operationName). The semantic difference is execution order: query root fields may execute in parallel; mutation root fields execute sequentially in the listed order. The operation string starts with query or mutation keyword. Shorthand queries (omitting the query keyword) are allowed for queries only — mutations always require the explicit keyword. The server response JSON structure is identical for both operation types.
Further reading and primary sources
- GraphQL over HTTP Specification — Official specification for the GraphQL JSON wire format, request/response structure, and HTTP transport rules
- GraphQL Specification: Variables — GraphQL spec section covering variable declarations, types, nullability, and default values
- Apollo: Automatic Persisted Queries — Apollo Server and Client APQ documentation with SHA-256 hashing protocol and GET request caching
- graphql-scalars: JSON Scalar — Production-ready JSON custom scalar implementation with validation and TypeScript types
- GraphQL Spec: Errors — GraphQL specification for the errors array format, path, locations, and extensions fields