API · Graphql

GraphQL Error Handling — A Field Guide

GraphQL Error Handling — A Field Guide is the work that makes the systems talk. The API is the contract between the producer and the consumer; the contract is what determines.

John Kihiu12 min read

REST leans on HTTP status codes to signal success or failure — a 404, a 422, a 500 each mean something before you've even parsed the body. GraphQL responses almost always come back with a 200, because the transport succeeded even if part of the operation didn't. Errors instead live inside the response body itself, in a structure the spec actually defines, and the interesting design decisions are less about the format and more about which errors belong there at all.

The top-level errors array

A GraphQL response is a JSON object with up to two top-level keys: data and errors. Critically, both can be present at once — a query with several fields can partially succeed, returning data for the fields that resolved and an errors entry for the one that threw. This is different from most REST conventions, where a response is either a success body or an error body, not both.

GRAPHQL · RESPONSE
{
  "data": {
    "post": {
      "title": "Shipping GraphQL",
      "author": null
    }
  },
  "errors": [
    {
      "message": "Author lookup failed: connection timeout",
      "locations": [{ "line": 3, "column": 5 }],
      "path": ["post", "author"],
      "extensions": { "code": "INTERNAL_SERVER_ERROR" }
    }
  ]
}

The spec-defined shape for each entry is message (human-readable), locations (where in the query document the error originated), path (which field in the response tree it corresponds to, letting a client find exactly which node is null because of it), and extensions — an open object for anything server-specific.

Using extensions.code for machine-readable errors

GraphQL deliberately doesn't standardize anything like HTTP status codes — there's no built-in enum of error kinds. The convention that's emerged, and that Apollo Server follows by default, is to put a stable, machine-readable string on extensions.code: things like UNAUTHENTICATED, FORBIDDEN, BAD_USER_INPUT, or INTERNAL_SERVER_ERROR. Clients branch on extensions.code, not on message, since the message is meant for a human and is free to change wording without breaking anyone's error handling.

Errors as data: union types for expected failures

The errors array is well suited to failures the client didn't cause and can't predict from the query shape — a timeout, a downstream outage, a bug. It's a poor fit for outcomes that are a normal, expected part of the business logic, like "wrong password" on a login mutation. Modeling those as thrown errors forces every client to special-case the errors array just to handle a routine case. The alternative — increasingly common in schema design — is a result union that puts expected outcomes directly in the schema's type system.

GRAPHQL · SCHEMA
union LoginResult = LoginSuccess | InvalidCredentialsError | AccountLockedError

type LoginSuccess {
  token: String!
  user: User!
}

type InvalidCredentialsError {
  message: String!
}

type AccountLockedError {
  message: String!
  retryAfterSeconds: Int!
}

type Mutation {
  login(email: String!, password: String!): LoginResult!
}

The client queries the union with inline fragments per type (... on LoginSuccess { token }, ... on AccountLockedError { retryAfterSeconds }), and the schema itself documents every outcome the mutation can produce — something the errors array can never express, since it isn't typed.

Where to draw the line

The practical split: reserve the top-level errors array for unexpected, system-level failures — the ones a well-behaved client can't be expected to plan a UI around — and model expected, business-level outcomes as part of the schema, either through result unions or nullable fields with a sibling reason code. A login failing because of a bad password is not exceptional; a downstream service timing out is.

Partial data plus partial errors is normal

A client that only checks if (errors) throw will discard perfectly good partial data. Design the UI to render whatever data came back and separately surface the fields that failed via path — that's the behavior the spec's partial-success model is built for.

Masking internals before they reach the client

Whatever throws inside a resolver — a database exception, a stack trace, a raw driver error — should not land verbatim in the message field of a production response; that's a direct path for leaking schema internals, connection strings, or implementation details to anyone querying the API. Apollo Server and most other implementations support formatting hooks that let you catch unhandled errors, log the full detail server-side, and rewrite what actually ships to the client down to a generic message plus a stable extensions.code.

Wrapping up

GraphQL error handling comes down to using the right channel for the right kind of failure: the errors array with extensions.code for the unexpected, a typed result union in the schema for the expected, and a formatting layer that keeps whatever leaks out of a resolver's stack trace from reaching a client in production.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.