GraphQL Error Handling
Error handling in GraphQL works differently from REST. Instead of using HTTP status codes like 404 or 500 to signal problems, GraphQL always returns HTTP 200 and puts error information inside the response body. Understanding the two types of errors and how to handle each one makes your API far more reliable and your clients easier to build.
Two Types of GraphQL Errors
Type 1: System Errors Type 2: Business Errors ───────────────────── ─────────────────────── Unexpected failures Expected application-level failures Server crash, DB down Email taken, password too short Unhandled exceptions Insufficient funds, item out of stock Go in: top-level "errors" array Go in: mutation payload data HTTP: still 200 OK HTTP: 200 OK Client: error-handling middleware Client: reads payload.errors
The GraphQL Error Response Structure
Partial success — some fields work, some fail:
───────────────────────────────────────────────
{
"data": {
"user": {
"name": "Vikram",
"posts": null ← posts resolver threw an error
}
},
"errors": [
{
"message": "Posts service is unavailable",
"locations": [{ "line": 4, "column": 5 }],
"path": ["user", "posts"],
"extensions": {
"code": "SERVICE_UNAVAILABLE"
}
}
]
}
Throwing Errors Inside Resolvers
Use GraphQL's built-in GraphQLError class to throw errors with structured metadata. The extensions object carries machine-readable error codes that clients can switch on.
import { GraphQLError } from 'graphql';
const resolvers = {
Query: {
user: async (_, args, ctx) => {
const user = await ctx.db.users.findById(args.id);
if (!user) {
throw new GraphQLError('User not found', {
extensions: {
code: 'NOT_FOUND',
userId: args.id,
httpStatus: 404,
}
});
}
return user;
}
}
};
Standard Error Codes
Code When to use ──── ─────────── UNAUTHENTICATED No valid token — user not logged in FORBIDDEN Logged in but lacks permission NOT_FOUND Requested resource does not exist BAD_USER_INPUT Client sent invalid argument values INTERNAL_SERVER_ERROR Unexpected server-side failure RATE_LIMITED Too many requests SERVICE_UNAVAILABLE Downstream service is down
Business Errors in Mutation Payloads
Validation failures and business rule violations belong in the mutation payload, not in the top-level errors array. This approach makes client handling predictable and avoids mixing system errors with user-facing messages.
type UserError {
field: String
message: String!
}
type RegisterPayload {
user: User
errors: [UserError!]!
}
// Resolver:
register: async (_, { input }) => {
const existing = await db.users.findByEmail(input.email);
if (existing) {
return {
user: null,
errors: [{
field: 'email',
message: 'An account with this email already exists'
}]
};
}
const user = await db.users.create(input);
return { user, errors: [] };
}
Error Masking in Production
Never expose internal error details (database errors, stack traces, file paths) to clients in production. Apollo Server masks unexpected errors automatically in production mode. Catch and rethrow database errors with safe, generic messages.
// Development — full error detail:
"message": "relation 'users' does not exist at character 15"
// Production — safe message:
"message": "Internal server error"
// In resolver — catch and rethrow safely:
try {
return await db.users.findById(id);
} catch (err) {
console.error('DB error:', err); // Log the real error server-side
throw new GraphQLError('Failed to load user', {
extensions: { code: 'INTERNAL_SERVER_ERROR' }
});
}
Key Points
- GraphQL returns HTTP 200 for all responses — errors live in the response body, not the status code.
- System errors go in the top-level
errorsarray; business errors belong in mutation payload data. - Use
GraphQLErrorwith anextensions.codeto give clients machine-readable error codes. - Mask internal error details in production — log the real error server-side only.
- A response can contain both partial data and errors simultaneously, allowing partial success.
