GraphQL Errors and Versioning
Two related design topics. Errors: how to communicate failure. Versioning: how the schema evolves over time (spoiler: there’s no version bump in GraphQL — schemas evolve additively).
Error response shape
The spec defines errors as a top-level array next to data:
{
"data": {
"user": null,
"posts": [{"title": "..."}, {"title": "..."}]
},
"errors": [
{
"message": "User not found",
"path": ["user"],
"locations": [{"line": 2, "column": 3}],
"extensions": {
"code": "NOT_FOUND",
"timestamp": "2024-01-15T10:30:00Z"
}
}
]
}
| Field | Means |
|---|---|
message |
human-readable |
path |
which field in the query failed (e.g. ["user", "posts", 2, "author"]) |
locations |
line/column in the query string |
extensions |
open dict — your custom fields (code, traceId, validation details) |
extensions.code is the de facto standard for machine-readable error codes:
NOT_FOUND
UNAUTHENTICATED
FORBIDDEN
BAD_USER_INPUT
INTERNAL_SERVER_ERROR
GRAPHQL_VALIDATION_FAILED
GRAPHQL_PARSE_FAILED
Apollo Server and most libraries use these by default.
Partial responses
GraphQL allows partial success:
{
"data": {
"user": {"name": "Alice", "posts": null},
"trending": [{"title": "..."}]
},
"errors": [
{"message": "Failed to load posts", "path": ["user", "posts"]}
]
}
The user.posts field failed; everything else returned successfully. Clients render what they have and show error UI for the failed field.
For nullable fields, the parent stays intact. For non-null fields:
type User {
name: String!
posts: [Post!]! # non-null
}
If posts resolver errors, GraphQL bubbles up: user becomes null. If user is also non-null, the bubble continues to root. Non-null = “if this fails, lose the whole branch.”
Design implication: use nullable for fields that can fail independently. Non-null only when you’re certain it can’t fail or you want the whole-branch-fails behavior.
“Errors as data” pattern
For business-logic errors (validation, conflict, not-found by request), top-level errors is awkward — clients have to inspect errors[].extensions.code and parse path.
Better: model expected failures as part of the schema:
union LoginResult =
| LoginSuccess
| InvalidCredentials
| AccountLocked
| MFARequired
type LoginSuccess {
token: String!
user: User!
}
type InvalidCredentials {
message: String!
remainingAttempts: Int!
}
type AccountLocked {
unlockAt: DateTime!
}
type MFARequired {
challengeId: ID!
}
type Mutation {
login(email: String!, password: String!): LoginResult!
}
Client query:
mutation {
login(email: "...", password: "...") {
__typename
... on LoginSuccess { token user { name } }
... on InvalidCredentials { message remainingAttempts }
... on AccountLocked { unlockAt }
... on MFARequired { challengeId }
}
}
Each case is exhaustively typed. Frontend can’t forget to handle “account locked” — TypeScript codegen makes it a compile error.
Top-level errors is reserved for unexpected failures (network blip, server bug, auth failure). Business outcomes are in data.
This is the modern best practice; pioneered by Relay. Drawback: more schema surface area.
Logging GraphQL errors
The “always 200” model hides errors from HTTP-based monitoring. You need GraphQL-level instrumentation:
async def log_errors(execution_result):
for error in execution_result.errors or []:
logger.error(
"GraphQL error",
extra={
"message": str(error),
"path": error.path,
"operation": execution_result.operation_name,
"user_id": current_user_id(),
},
)
Hook into the executor (Schema.execute post-processing in Strawberry/Ariadne). Separate operational errors (your server) from user errors (their input).
Masking errors in production
In dev: full error details with stack traces. In production: scrub internal error messages — they leak schema info, SQL queries, file paths.
def format_error(error):
if isinstance(error.original_error, (ValidationError, NotFoundError)):
return {"message": str(error), "code": "USER_ERROR"}
# internal — don't leak details
logger.exception("internal error", exc_info=error.original_error)
return {"message": "Internal server error", "code": "INTERNAL_ERROR"}
Apollo Server, Strawberry, GraphQL-Yoga all have hooks for this.
Versioning — the absence of versioning
GraphQL doesn’t have versions. There’s no /v2/graphql. The schema is one living document that evolves.
The thesis:
- Adding fields/types is non-breaking.
- Clients only fetch fields they ask for.
- Removing/renaming requires deprecation + migration.
This works because clients are explicit about what they want. A 2-year-old mobile app still requests the same fields it always did; new fields don’t break it.
Additive evolution
Safe changes:
- Add a new field to an existing type.
- Add a new query / mutation.
- Add a new optional argument.
- Add a new enum value (with caveats — see below).
- Add a new type.
- Make a non-null type nullable (in OUTPUT positions; breaks INPUT clients).
Most schema growth fits here. No version bump needed.
Breaking changes — @deprecated
type User {
fullName: String! @deprecated(reason: "Use firstName + lastName")
firstName: String!
lastName: String!
}
The deprecated field stays in the schema; tooling (Apollo Studio, GraphiQL) marks it visually. Clients see the warning; old clients still work.
After ~6–12 months of monitoring usage, when traffic on the deprecated field is zero, remove it.
For arguments and enum values:
type Query {
posts(
limit: Int @deprecated(reason: "Use first")
first: Int
): [Post!]!
}
enum Status {
ACTIVE
INACTIVE
ARCHIVED @deprecated(reason: "Use DELETED instead")
DELETED
}
Enum addition — almost safe
Adding an enum value breaks clients that exhaustively switch:
// generated TypeScript from old schema:
type Status = "ACTIVE" | "INACTIVE";
switch (post.status) {
case "ACTIVE": ...
case "INACTIVE": ...
// exhaustiveness check fails when "ARCHIVED" is added
}
If your clients exhaustively check, treat enum addition as breaking. Otherwise it’s fine. Document the convention.
Field nullability evolution
| Change | Output (response) | Input (argument) |
|---|---|---|
| Non-null → nullable | breaking | safe |
| Nullable → non-null | safe (for clients reading) | breaking (clients sending null) |
Confusing — output and input are opposite. Think about the client’s perspective:
- For outputs they read: they expect non-null; making it nullable breaks their assumptions. Making it non-null is fine — they were already handling null.
- For inputs they send: they were sending nullable; making it required breaks them. Making it optional (from required) is fine.
Schema tracking
Apollo Studio, Hasura Cloud, Inigo, or self-hosted tools (graphql-inspector) track:
- All distinct queries hitting your server.
- Which fields are used and by whom.
- Schema diffs over time.
Critical for safe evolution. Without tracking, you don’t know if deprecated fields are still used.
# graphql-inspector — diff schemas
npx @graphql-inspector/cli diff schema-old.graphql schema-new.graphql
Run in CI; block PRs that introduce breaking changes without explicit override.
When versioning IS necessary
Rare cases:
- Major paradigm shift (REST/GraphQL gateway switch).
- Compliance / security mandate (cipher upgrade incompatible with old schema).
- The client base has a stable upgrade path you can coordinate.
Then: run v1 and v2 schemas at different endpoints. Or use schema gateways (Apollo Federation, Hasura) to compose them.
In practice: ship a new field; deprecate the old; migrate. Versioning is rarely needed.
Federation and versioning across services
Apollo Federation lets multiple GraphQL services contribute to one schema. Each service evolves its piece independently:
# Service A
type User @key(fields: "id") {
id: ID!
name: String!
}
# Service B (extends User)
extend type User @key(fields: "id") {
id: ID! @external
posts: [Post!]!
}
Each service has its own deploy cadence; the federated gateway composes. Versioning becomes about service-level deploys, not schema versions.
Common pitfalls
- No error monitoring because HTTP 200 hides issues. Hook the executor for GraphQL-level logging.
- Leaking internal errors in production responses. Mask except for typed business errors.
- Removing fields without deprecation — clients break in production. Always deprecate first.
- No schema tracking — flying blind on which fields are actually used.
- Treating enum addition as fully safe — exhaustive-switch clients break.
Common interview confusions
- “GraphQL has no errors.” — has the
errorsarray; just doesn’t use HTTP status codes for it. - “GraphQL versioning means deploying
v2/graphql.” — usually no version. Evolve additively, deprecate, remove. - “Always 200 means everything succeeded.” — opposite: server received the request. Check
errors.
Interview angle
- “How does GraphQL communicate errors?” —
errorsarray at the response top level (alongsidedata). Each error hasmessage,path(which field),locations(line/column in query),extensions.code(machine-readable). HTTP status stays 200. - “What’s the ‘errors as data’ pattern?” — instead of using top-level errors for business outcomes, return a union of typed result types from mutations. Exhaustive client handling, cleaner UX. Top-level errors reserved for unexpected failures.
- “How does GraphQL handle versioning?” — it doesn’t. Schemas evolve additively (new fields, new types). Breaking changes use
@deprecated+ monitor usage + remove when traffic ceases. Nov2/graphql. - “What kinds of changes are safe vs breaking?” — safe: add fields, types, optional args, enum values (with caveats). Breaking: remove/rename fields, change types, tighten validation, make output non-null nullable.
- “How do you safely remove a field?” — mark
@deprecated(reason: "..."), monitor usage via tooling (Apollo Studio, graphql-inspector), remove when zero traffic. Typically 6–12 months for major clients. - “How do partial responses work?” — for nullable fields that error: the field is set to null, error added to
errors, sibling fields unaffected. For non-null fields that error: the null bubbles up the tree until it reaches a nullable parent.