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 body

After 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 query field 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, the operationName field 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 Int 42), 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 a PersistedQueryNotFound error 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 JSON in the schema and implemented server-side with serialize, parseValue, and parseLiteralfunctions 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 data object and a non-empty errors array, 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. The path array 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 input keyword 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