Skip to main content

Class 8: Error Handling

Understand how GraphQL handles errors and how to design for graceful failures.

Duration: ~15 minutes | Difficulty: Beginner | Prerequisites: Class 5


GraphQL Error Philosophy

GraphQL handles errors differently from REST. The key insight:

A GraphQL response can contain both data AND errors.

This enables partial responses - some fields succeed while others fail, and the client receives both.

{
"data": {
"movie": {
"title": "Inception",
"director": null
}
},
"errors": [
{
"message": "Director service unavailable",
"path": ["movie", "director"]
}
]
}

The client gets the movie title even though the director lookup failed.


Response Structure

Every GraphQL response has this structure:

{
"data": { ... }, // Query results (may be partial)
"errors": [ ... ], // Array of errors (if any)
"extensions": { ... } // Optional metadata
}

Success (No Errors)

{
"data": {
"movie": {
"title": "The Matrix",
"releaseYear": 1999
}
}
}

Partial Success

{
"data": {
"movie": {
"title": "The Matrix",
"externalRating": null
}
},
"errors": [
{
"message": "External rating service timeout",
"path": ["movie", "externalRating"]
}
]
}

Complete Failure

{
"data": null,
"errors": [
{
"message": "Authentication required",
"extensions": {
"code": "UNAUTHENTICATED"
}
}
]
}

Error Object Anatomy

Each error in the errors array has these fields:

{
"message": "Cannot return null for non-nullable field Movie.title",
"locations": [
{ "line": 3, "column": 5 }
],
"path": ["movie", "title"],
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"timestamp": "2024-01-15T10:30:00Z"
}
}
FieldRequiredDescription
messageYesHuman-readable error description
locationsNoWhere in the query the error occurred
pathNoWhich field in the response failed
extensionsNoCustom metadata (error codes, etc.)

Error Types

The GraphQL spec (Section 7) groups errors into two categories:

  • Request errors are raised before execution begins. They prevent the response from having any data produced. Syntax errors, validation errors, and variable-coercion failures are all request errors. Under application/graphql-response+json the data entry must be absent for a request error; under the legacy application/json media type, servers commonly return data: null.
  • Execution errors (formerly called "field errors" in earlier spec versions) are raised during field resolution. The response can include both data (with successfully resolved fields) and errors (describing the failures). This is what enables partial responses.

1. Syntax Errors (request error)

Invalid GraphQL syntax - the query can't be parsed.

query Movie {
movie(id: "1" { # Missing closing parenthesis
title
}
}
{
"errors": [
{
"message": "Syntax Error: Expected Name, found \"{\".",
"locations": [{ "line": 2, "column": 17 }]
}
]
}

Note: The specification requires the data entry to be absent for a request error: "The data entry in this map must not be present, the errors entry must include the error, and request execution should be halted." Plenty of deployed servers send "data": null instead. Client code that decides success by testing whether data is present misreads those responses, so read the errors array.

2. Validation Errors (request error)

Query is syntactically valid but violates the schema.

query MovieWithUnknownField {
movie(id: "1") {
title
nonExistentField # Field doesn't exist
}
}
{
"errors": [
{
"message": "Cannot query field 'nonExistentField' on type 'Movie'",
"locations": [{ "line": 4, "column": 5 }]
}
]
}

3. Execution Errors (field error)

Query is valid but something went wrong during execution. The response can include both data (with the parts that succeeded) and errors.

query MovieWithDirector {
movie(id: "1") {
title
director {
name
}
}
}
{
"data": {
"movie": {
"title": "The Matrix",
"director": null
}
},
"errors": [
{
"message": "Director service unavailable",
"path": ["movie", "director"],
"extensions": {
"code": "SERVICE_UNAVAILABLE"
}
}
]
}

Null Propagation

When a field fails, GraphQL follows null propagation rules:

This is the behavior every ratified edition defines, September 2025 included, and it is also the piece of GraphQL under the most active revision. The Nullability Working Group closed in February 2026 having settled on a different direction. That is a request-level onError parameter, paired with a way for a service to advertise that it supports it. It would let a client ask the server to write null at the position that failed, instead of propagating upward. That work is at the proposal stage in the specification repository, and graphql-js v17 ships an experimental error mode ahead of it. Treat propagation as today's default and not as a permanent property of the language.

Null Propagation

Nullable field (director: Director):

  • Error in director → director: null
  • Query continues, other fields returned

Non-null field (director: Director!):

  • Error in director → null propagates UP to parent
  • If parent is nullable: movie: null
  • If parent is also non-null: propagates further up until a nullable field or data: null

Example: Nullable Field

type Movie {
title: String!
director: Director # Nullable
}

If director fails:

{
"data": {
"movie": {
"title": "The Matrix",
"director": null
}
},
"errors": [{ "message": "...", "path": ["movie", "director"] }]
}

Example: Non-Null Field

type Movie {
title: String!
director: Director! # Non-null
}

If director fails:

{
"data": {
"movie": null
},
"errors": [{ "message": "...", "path": ["movie", "director"] }]
}

The entire movie becomes null because director was required but couldn't be returned.


Error Extensions

Use extensions for machine-readable error information:

{
"errors": [
{
"message": "You don't have permission to view this movie",
"path": ["movie"],
"extensions": {
"code": "FORBIDDEN",
"requiredPermission": "movies:read",
"userPermissions": ["movies:list"]
}
}
]
}

Common Error Codes

# Convention (not standardized by the spec, but widely used).
# Apollo Server ships four of these as built-in codes: GRAPHQL_PARSE_FAILED,
# GRAPHQL_VALIDATION_FAILED, BAD_USER_INPUT and INTERNAL_SERVER_ERROR.
# UNAUTHENTICATED and FORBIDDEN were built-in classes in Apollo Server 3;
# since Apollo Server 4 you define them yourself, and Apollo's docs still
# recommend exactly those two names.
UNAUTHENTICATED # No valid credentials
FORBIDDEN # Authenticated but not authorized
BAD_USER_INPUT # Invalid argument values
GRAPHQL_PARSE_FAILED # Request error: parse failed
GRAPHQL_VALIDATION_FAILED # Request error: validation failed
INTERNAL_SERVER_ERROR # Unexpected server error
NOT_FOUND # Resource doesn't exist (community convention)
SERVICE_UNAVAILABLE # External service down (community convention)
RATE_LIMITED # Too many requests (community convention)

HTTP Status Codes

This trips up many newcomers: a GraphQL response that includes errors is typically still HTTP 200 OK, not 4xx/5xx. Errors are reported in the body's errors array, and the HTTP layer is reserved for transport-level concerns (the request reached the server, the response is well-formed, etc.).

The GraphQL-over-HTTP spec (Stage 2: Draft) is stricter than that, and it changed during 2026. application/json is now listed only as the media type for GraphQL requests. Every conforming server must be able to answer with application/graphql-response+json, and the spec calls a server that cannot a legacy server that does not conform. Under that response media type, a result with data and an empty errors list is 200 OK, and a request error that stopped execution is a 4xx. A partial success carrying both data and errors gets the spec's custom 294 Partial Success code, chosen so that intermediaries and log pipelines can tell the two apart while clients keep reading the body.

In practice, most clients still see 200 OK from servers that predate this, so the status line alone cannot tell you what happened. Check response.errors to decide whether the request succeeded.


Designing for Errors

Strategy 1: Nullable Fields for Graceful Degradation

type Movie {
id: ID!
title: String!
# These can fail independently
director: Director # Nullable - can fail gracefully
externalRating: Float # Nullable - external service might be down
streamingLinks: [Link!] # Nullable - might not be available
}

Strategy 2: Result Types for Expected Errors

For mutations where errors are expected (validation, business rules):

type Mutation {
createMovie(input: CreateMovieInput!): CreateMovieResult!
}

union CreateMovieResult = CreateMovieSuccess | CreateMovieError

type CreateMovieSuccess {
movie: Movie!
}

type CreateMovieError {
message: String!
code: ErrorCode!
field: String
}

Mutation:

mutation CreateMovie($input: CreateMovieInput!) {
createMovie(input: $input) {
... on CreateMovieSuccess {
movie { id title }
}
... on CreateMovieError {
message
code
field
}
}
}

Strategy 3: Errors as Data

For multiple field-level errors:

type CreateMoviePayload {
movie: Movie
errors: [UserError!]!
}

type UserError {
field: String!
message: String!
code: String!
}

Response:

{
"data": {
"createMovie": {
"movie": null,
"errors": [
{ "field": "releaseYear", "message": "Must be after 1888", "code": "INVALID_YEAR" },
{ "field": "title", "message": "Already exists", "code": "DUPLICATE" }
]
}
}
}

Client-Side Error Handling

Check for Errors First

const response = await graphqlClient.query(GET_MOVIE, { id: "1" });

if (response.errors) {
// Handle errors
response.errors.forEach(error => {
console.error(`Error at ${error.path}: ${error.message}`);

// Check error code
if (error.extensions?.code === 'UNAUTHENTICATED') {
redirectToLogin();
}
});
}

// Even with errors, data might be partially available
if (response.data?.movie) {
displayMovie(response.data.movie);
}

Categorize by Error Code

function handleGraphQLErrors(errors) {
for (const error of errors) {
switch (error.extensions?.code) {
case 'UNAUTHENTICATED':
return { action: 'redirect', target: '/login' };
case 'FORBIDDEN':
return { action: 'show', message: 'Access denied' };
case 'NOT_FOUND':
return { action: 'show', message: 'Not found' };
case 'BAD_USER_INPUT':
return { action: 'validate', fields: error.extensions.fields };
default:
return { action: 'show', message: 'Something went wrong' };
}
}
}

Best Practices

1. Use Meaningful Error Messages

// ❌ Bad
{ "message": "Error" }

// ✅ Good
{ "message": "Movie with ID '999' not found" }

2. Include Error Codes

// ❌ Bad - client must parse message
{ "message": "You must be logged in" }

// ✅ Good - machine-readable code
{
"message": "You must be logged in",
"extensions": { "code": "UNAUTHENTICATED" }
}

3. Don't Expose Internal Details

// ❌ Bad - exposes implementation
{ "message": "SQLException: connection refused to postgres:5432" }

// ✅ Good - user-friendly, logs internal detail
{ "message": "Database temporarily unavailable" }

4. Use Path for Field-Level Errors

The path describes the response location of the failed field, not the input argument. Path segments are strings for field names and 0-indexed integers for list positions.

{
"message": "Could not fetch rating for review",
"path": ["movie", "reviews", 2, "rating"],
"extensions": { "code": "INTERNAL_ERROR" }
}

For input validation, surface the offending argument inside extensions instead, since path is reserved for response fields.

{
"message": "Rating must be between 0 and 10",
"path": ["createReview"],
"extensions": {
"code": "BAD_USER_INPUT",
"argument": "input.rating"
}
}

Summary

ConceptDescription
Partial ResponseData and errors can coexist
Null PropagationFailed non-null fields null their parent
Error PathShows which field failed
ExtensionsMachine-readable error metadata
Error CodesConventional codes (not in the spec) for client handling

What's Next?

In the next class, we'll explore Class 9: Validation & Execution - how GraphQL processes queries from parsing to response.