When a GraphQL server breaks, it doesn't look broken. Your load balancer sees HTTP 200. Your uptime monitor sees success. Your user sees partial data. Understanding GraphQL errors means grasping a fundamental architectural choice: GraphQL always returns HTTP 200, even when critical parts of your schema fail. The actual errors live in a structured errors array that sits alongside whatever data was successfully resolved. This field-level error model is powerful—it lets clients handle missing data gracefully—but it blinds every HTTP-based monitoring tool. A service can be 100% broken and look 100% healthy.
This post explains the GraphQL error model from first principles, shows how to design reliable error handling into your schema, and teaches you how to monitor what HTTP status codes can't see.
The Response Shape: Data, Errors, Extensions
A GraphQL response is always a JSON object with three possible fields:
{
"data": { /* actual data for fields that resolved */ },
"errors": [ /* errors encountered during execution */ ],
"extensions": { /* optional context from the server */ }
}
Here's a real example: imagine a query that fetches a user and their posts, but the posts resolver fails:
{
"data": {
"user": {
"id": "123",
"name": "Alice",
"posts": null
}
},
"errors": [
{
"message": "Database connection timeout",
"path": ["user", "posts"],
"locations": [{"line": 3, "column": 5}],
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exception": {
"stacktrace": [...]
}
}
}
]
}
This response is HTTP 200—but it's a failure. The user object returned successfully, but posts is null. The errors array tells you why: a database timeout. The path field shows exactly where in the query the error occurred. The extensions object carries machine-readable metadata, most importantly a code for structured error handling.
Both data and errors can be present in the same response. This partial success is not an edge case—it's the normal GraphQL error model. A missing data field entirely means the query couldn't even be parsed or validated.
Partial Data and Null Propagation
The reason GraphQL returns partial success is a design principle: nullability is your error boundary. When a field errors, GraphQL doesn't crash the whole request. Instead:
- If the erroring field is nullable (e.g.,
posts: [Post]notposts: [Post]!), the field is set to null and execution continues. - If the erroring field is non-nullable (e.g.,
id: ID!), the error propagates up to the nearest nullable parent and that parent becomes null.
Here's the schema:
type Query {
user(id: ID!): User
}
type User {
id: ID!
name: String!
posts: [Post] # Nullable list
email: String! # Non-nullable field
}
type Post {
id: ID!
title: String!
}
If the user.email resolver throws an error and email is non-nullable, the entire user object becomes null. The error propagates up. But if user.posts throws, only posts becomes null—the user object still returns.
This is the single most surprising aspect of GraphQL error handling. A non-nullable field that errors is treated as a schema-level bug, not a recoverable failure. Use non-nullable fields only for data that truly should never be absent; use nullable fields for data that might legitimately fail to load.
{
"data": {
"user": null
},
"errors": [
{
"message": "User email not found",
"path": ["user", "email"],
...
}
]
}
The client asked for a user, got one back, but then the email resolver failed. Since email is non-nullable, the entire user object is null.
When You Do Get a Non-200
GraphQL isn't always HTTP 200. The spec distinguishes several cases:
Malformed query (request-level error): If the JSON payload isn't valid JSON or the query string isn't valid GraphQL syntax, you get HTTP 400 and a data: null response. This is a client error.
Validation error: If the query is syntactically valid GraphQL but doesn't match your schema (e.g., requesting a field that doesn't exist), you get HTTP 400 and an errors array with no data field. This is also a client error.
Execution errors: These return HTTP 200 with data and/or errors as described above.
Some newer GraphQL implementations (particularly those following the application/graphql-response+json media type) also use HTTP 400 for certain execution failures, but this is still emerging. The safest assumption is that a successful POST /graphql returns 200 regardless of content.
Never rely solely on HTTP status codes for GraphQL monitoring. Always parse the errors array.
Errors as Data: The Union Result Pattern
The most mature GraphQL services don't model all failures as exceptions. Instead, they distinguish between expected failures (validation, not found, insufficient permissions) and exceptional failures (database crash, external API timeout).
Expected failures are modeled in your schema using union types or error result objects:
type Query {
createPost(input: CreatePostInput!): CreatePostResult!
}
union CreatePostResult = CreatePostSuccess | ValidationError | NotFoundError
type CreatePostSuccess {
post: Post!
}
type ValidationError {
message: String!
field: String!
}
type NotFoundError {
message: String!
resourceId: ID!
}
Now a client can request:
mutation {
createPost(input: {...}) {
... on CreatePostSuccess {
post { id title }
}
... on ValidationError {
message field
}
... on NotFoundError {
message resourceId
}
}
}
This always returns HTTP 200 and never uses the errors array for these cases. The response shape makes the possible outcomes explicit in the schema. A validation error isn't an exception—it's a typed result the client must handle.
Reserve the errors array for genuinely exceptional cases: database connections lost mid-query, external service timeouts, or server bugs. This separation makes monitoring clearer and error handling predictable.
Machine-Readable Errors: The extensions.code Convention
The extensions.code field is where GraphQL servers communicate structured error types. Apollo Server, the most common implementation, defines codes like:
GRAPHQL_PARSE_FAILEDGRAPHQL_VALIDATION_FAILEDINTERNAL_SERVER_ERRORUNAUTHENTICATEDFORBIDDENBAD_USER_INPUTBAD_REQUEST
A client can parse this field programmatically:
if (response.errors) {
const error = response.errors[0];
if (error.extensions.code === "UNAUTHENTICATED") {
// Refresh the auth token and retry
} else if (error.extensions.code === "FORBIDDEN") {
// Show a permission error to the user
} else if (error.extensions.code === "INTERNAL_SERVER_ERROR") {
// Report to error tracking and show a generic message
}
}
When you instrument your error tracking, tag errors by extensions.code. This lets you separate user-facing errors from infrastructure failures and correlate error trends by type.
Security: Leaking Stack Traces and Schema Details
By default, GraphQL servers leak dangerous information through the errors array. A resolver that throws an exception includes a full stack trace in extensions.exception.stacktrace. An error message might expose internal database column names or API details.
Always mask errors in production. Use an error formatter hook:
const server = new ApolloServer({
typeDefs,
resolvers,
formatError(err) {
// Log the real error for debugging
console.error(err);
// Return a safe error to the client
return {
message: "An error occurred",
extensions: {
code: err.extensions.code,
// Never send stack traces or detailed messages to clients
},
};
},
});
Similarly, introspection queries expose your entire schema to anyone with access to your endpoint. In production, disable introspection or restrict it to authenticated users. And never expose field suggestions (autocomplete hints) to unauthenticated clients—they leak your schema structure.
Monitoring: Per-Operation, Not Per-URL
Here's the critical insight: you cannot monitor GraphQL with URL-based metrics. All GraphQL queries and mutations hit a single endpoint, usually POST /graphql. If you track errors per-URL, every query—successful or not—aggregates into one metric.
Instead, monitor by operation name. Every GraphQL request has a name:
query GetUserPosts {
user(id: $userId) {
name
posts { title }
}
}
mutation CreatePost {
createPost(input: $input) {
... on CreatePostSuccess { post { id } }
... on ValidationError { message }
}
}
For framework-specific SDK setup details, see GraphQL server error tracking. Here, we focus on the error model and operation-level instrumentation strategy.
When you capture errors, tag them with the operation name:
server.addPlugin({
didResolveOperation({ request, document }) {
Sentry.setTag("graphql.operation", request.operationName || "Anonymous");
},
didEncounterErrors(ctx) {
for (const err of ctx.errors) {
Sentry.captureException(err, {
tags: {
"graphql.operation": ctx.request.operationName,
"graphql.path": err.path?.join("."),
},
contexts: {
graphql: {
operationName: ctx.request.operationName,
},
},
});
}
},
});
Point this at LightTrace, which speaks the Sentry protocol. Now you'll see which operations are failing, how often, and which resolvers are involved. You can alert on "GetUserPosts operation saw 10 errors in the last hour" instead of meaningless aggregate /graphql metrics.
Also instrument your resolvers with distributed tracing. A slow resolver buried deep in your schema won't show up in endpoint-level latency metrics. Use span timing to surface which resolver is causing the latency:
async function resolveUserPosts(parent, args) {
const span = Sentry.startTransaction({
op: "graphql.field",
name: "User.posts",
});
try {
const posts = await db.query(
"SELECT * FROM posts WHERE user_id = ?",
[parent.id]
);
return posts;
} finally {
span.finish();
}
}
This won't show in reduce API latency guides that focus on endpoints—it's a field-level concern, which is GraphQL's core insight.
Schema-Driven Error Design
The best GraphQL error handling starts in your schema. Decide upfront:
-
Which errors are data? Use union types for expected failures (validation, not found, permission denied). These belong in your response shape, not the
errorsarray. -
Which resolvers should be non-nullable? Only mark fields non-nullable if they truly should never fail. Most real-world data should be nullable so that a single resolver failure doesn't null out a whole object.
-
What codes matter? Define error codes for your domain (e.g.,
INVALID_EMAIL,USER_NOT_FOUND,INSUFFICIENT_BALANCE). Useextensions.codeconsistently. -
How will you monitor? Instrument by operation name and field path, not by URL.
Combine this with error grouping and fingerprinting in your error tracker so that recurring issues bubble up fast. And for particularly sensitive fields, use structured logging to capture additional context before the error occurs.
Start tracking errors in minutes
Monitor your GraphQL operation errors with LightTrace. Point your Sentry SDK at LightTrace with a single DSN change, capture resolver errors by operation name, and see which fields are failing and why—all in a fast dashboard built for distributed tracing and error grouping.