GraphQL Error Handling Best Practices
Compare the errors array, union result types, and partial responses to design predictable, typed error handling for your GraphQL APIs and clients.
What you'll learn
- ✓Why the errors array is not enough on its own
- ✓How to model expected errors as schema types
- ✓Union and interface-based error patterns
- ✓Partial data and null propagation rules
- ✓Client patterns for typed error handling
Prerequisites
- •Familiar with HTTP and databases
What and Why
In REST, errors travel via HTTP status codes. GraphQL responses are always 200 OK (when the request is well-formed), so errors are surfaced inside the response body. The spec defines an errors array alongside data, but it is intentionally minimal: a message, a path, and locations. That works for transport-level problems, but it is a poor fit for business-domain errors like “email already taken” or “insufficient balance”.
Mature GraphQL APIs treat errors as part of the schema. Expected failures become typed results, and unexpected failures fall back to the errors array. This split makes clients more reliable and reduces the temptation to parse error strings.
Mental Model
Sort errors into two buckets:
- Exceptional: bugs, infrastructure failures, auth problems. These belong in the
errorsarray. They are not part of your domain contract. - Expected: validation failures, conflicts, business-rule rejections. These belong in your schema as part of a mutation’s result type.
The first kind is handled by middleware: logging, alerting, retries. The second kind is handled by your UI: showing inline form errors, prompting the user to confirm an action, switching to a different flow.
Hands-on Example
A login mutation might return a union of success and failure states:
type AuthSuccess { token: String!, user: User! }
type InvalidCredentials { message: String! }
type AccountLocked { unlockAt: DateTime! }
union LoginResult = AuthSuccess | InvalidCredentials | AccountLocked
type Mutation {
login(email: String!, password: String!): LoginResult!
}
Clients use inline fragments to handle each case explicitly:
mutation Login($e: String!, $p: String!) {
login(email: $e, password: $p) {
__typename
... on AuthSuccess { token user { id } }
... on InvalidCredentials { message }
... on AccountLocked { unlockAt }
}
}
Request
|
v
+-----------------+
| Resolver |
+-----------------+
| |
expected unexpected
| |
v v
data field errors[]
(union) (message, path)
| |
v v
UI branches log + alert
For unexpected failures, throw inside the resolver and let the server’s error formatter strip stack traces, attach a code, and emit it via the errors array:
throw new GraphQLError('Internal error', {
extensions: { code: 'INTERNAL_SERVER_ERROR' },
});
Common Pitfalls
- Overloading the
errorsarray with validation messages. Clients then parse strings or codes from an untyped channel and break when wording changes. - Returning
nullfor a non-null field. GraphQL propagates the null up the tree, often nulling sibling data. Use nullable result types or unions instead. - Forgetting to handle every member of a union. New error variants silently fall through. Use codegen and exhaustive switches to catch this at build time.
- Mixing HTTP status codes with GraphQL semantics. Returning 500 because a mutation failed validation breaks gateways and client retry logic.
- Leaking internal details (SQL fragments, stack traces) through error messages. Sanitize at the formatter layer, log the original separately.
- Treating all errors as retriable. Auth and validation errors should never be retried; transport errors usually should.
Practical Tips
Define a base Error interface in your schema so common fields (message, code) are queryable across variants. Concrete error types extend it with structured fields like field, unlockAt, or retryAfter.
Adopt a stable error code enum. Codes outlive messages and are safer for client logic. Treat the message as user-facing copy that may change.
Use a result wrapper pattern consistently across mutations: XxxPayload types that contain the success object and a list of typed errors. This is verbose but keeps clients uniform.
Pair errors with codegen. Generated TypeScript types make exhaustive handling enforceable and document the contract for free.
On the client, centralize the bridge between errors[] and user feedback. A single hook or middleware that maps codes to toasts, redirects, or logs keeps UI code clean.
Wrap-up
The default errors array is fine for plumbing failures, but business errors deserve first-class schema treatment. Model expected failure as types, reserve the errors array for true exceptions, and lean on codegen to keep client handling exhaustive. Your API becomes more honest, and your clients become much harder to break.
Related articles
- GraphQL GraphQL Pagination: Relay-Style Connections
Understand cursor-based pagination using the Relay connection spec, why it scales better than offsets, and how to implement it cleanly in your schema.
- GraphQL GraphQL Schema Design Best Practices
Practical patterns for designing GraphQL schemas that age well: nullability, pagination, mutations, errors, and evolution without versioning.
- GraphQL GraphQL Schemas and Resolvers
A practical guide to GraphQL schemas — SDL, scalar types, Query and Mutation roots, custom object types, resolver functions, and how to avoid the N+1 problem with DataLoader.
- GraphQL GraphQL Caching with Apollo Client
How Apollo Client's normalized cache works, why entity IDs matter, and the patterns for cache updates, refetches, and consistent UI after mutations.