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
A GraphQL response is a map holding at most three entries, and the specification's Response Format section permits only these three:
{
"data": { ... }, // Query results (may be partial)
"errors": [ ... ], // Errors raised, when there are any
"extensions": { ... } // Optional metadata
}
Which of them appear depends on what happened during the request, and each has its own subsection in the specification. errors appears only when at least one error was raised, and it is always a non-empty list (7.1.1). data appears whenever execution ran, and the key is missing when a request failed first (7.1.3). extensions is optional everywhere, and 7.1.7 reserves it "for implementers to extend the protocol however they see fit".
Additional Entries is what closes the list: the result maps "must not contain any entries other than those described above".
Success (No Errors)
Every field resolved, so the response carries data alone.
{
"data": {
"movie": {
"title": "The Matrix",
"releaseYear": 1999
}
}
}
Partial Success
One field failed and the rest resolved. data carries what succeeded, and the error's path names the position that failed.
{
"data": {
"movie": {
"title": "The Matrix",
"externalRating": null
}
},
"errors": [
{
"message": "External rating service timeout",
"path": ["movie", "externalRating"]
}
]
}
Complete Failure
The request failed before execution started. The specification calls this a request error result, and says the map "must not contain an entry with key data", so the key is missing entirely.
{
"errors": [
{
"message": "Authentication required",
"extensions": {
"code": "UNAUTHENTICATED"
}
}
]
}
A response carrying "data": null means something different: execution did start, and a failure in a non-null field travelled all the way up to the root. Null Propagation below covers that case.
Error Object Anatomy
Each error in the errors array is a map, and Error Result Format defines its keys:
{
"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"
}
}
| Field | Required | Description |
|---|---|---|
message | Always | Human-readable error description |
locations | Should appear when the error points at a place in the query | Where in the query the error occurred |
path | Must appear when the error points at a field in the result | Which field in the response failed |
extensions | Optional | Custom metadata (error codes, etc.) |
Only message is required outright. The rules for locations and path are conditional, and path is the stricter of the two: a server must send it whenever the error belongs to a field in the result, which is what lets a client tell an error null from a real one.
Error Types
The specification's Errors section groups errors into two categories:
- Request errors are raised before execution begins. They prevent the response from having any
dataproduced. Syntax errors, validation errors, and variable-coercion failures are all request errors. Underapplication/graphql-response+jsonthedataentry must be absent for a request error; under the legacyapplication/jsonmedia type, servers commonly returndata: 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) anderrors(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
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 the null propagation rules in Handling Execution Errors:
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, with a separate proposal covering how a service advertises that it supports the new behavior. 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.
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"]
}
}
]
}
Error Result Format reserves this map for implementers and lets them fill it however they see fit, which is why error codes live here. The response has a top-level extensions entry of its own, defined in section 7.1.7 and shown in Response Structure above. The two are separate: the top-level one carries metadata about the whole response, such as tracing or query cost, and the one above carries information about a single error.
Common Error Codes
The specification leaves error codes to implementers, so the names below are convention. Apollo Server ships GRAPHQL_PARSE_FAILED, GRAPHQL_VALIDATION_FAILED, BAD_USER_INPUT and INTERNAL_SERVER_ERROR as built-in codes. UNAUTHENTICATED and FORBIDDEN were built-in classes in Apollo Server 3, and from Apollo Server 4 onward you define them yourself, though Apollo's documentation still recommends 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 that carries data and omits errors 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
Mark a field nullable when the data behind it can fail on its own. A failure then stays at that field, and the rest of the movie still reaches the client.
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
}
Each outcome is its own type, so the schema itself lists what can go wrong and introspection shows it to the client.
The client picks the fields of each member with inline fragments:
mutation CreateMovie($input: CreateMovieInput!) {
createMovie(input: $input) {
... on CreateMovieSuccess {
movie { id title }
}
... on CreateMovieError {
message
code
field
}
}
}
Strategy 3: Errors as Data
A payload type carries the entity and a list of errors side by side, which suits forms where several inputs can fail at once.
type CreateMoviePayload {
movie: Movie
errors: [UserError!]!
}
type UserError {
field: String!
message: String!
code: String!
}
A rejected call returns a null movie and one entry per problem, and the field tells the form which input to mark:
{
"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
Read errors before you read data. A response can carry both, so treating the presence of data as success hides the failures sitting next to it.
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
Keep the reaction to each code in one place, so every screen answers the same failure the same way.
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
The message is for the developer reading logs, so name the thing that failed and the value that caused it.
// ❌ Bad
{ "message": "Error" }
// ✅ Good
{ "message": "Movie with ID '999' not found" }
2. Include Error Codes
A code gives client code a value that survives rewording, localization and added detail. Messages change as normal maintenance, and any client matching on their text breaks when they do.
// ❌ 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
Anyone probing your API reads its error responses closely, so keep stack traces, class names, SQL and hostnames on the server and send the user a plain description.
// ❌ 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
| Concept | Description |
|---|---|
| Partial Response | Data and errors can coexist |
| Null Propagation | Failed non-null fields null their parent |
| Error Path | Shows which field failed |
| Extensions | Machine-readable error metadata |
| Error Codes | Conventional 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.