Backend / Protocols / GraphQL / 06_errors_versioning.md

GraphQL Errors and Versioning

Updated 6 interview angles 7 min read source
On this page16
  1. Error response shape
  2. Partial responses
  3. “Errors as data” pattern
  4. Logging GraphQL errors
  5. Masking errors in production
  6. Versioning — the absence of versioning
  7. Additive evolution
  8. Breaking changes — @deprecated
  9. Enum addition — almost safe
  10. Field nullability evolution
  11. Schema tracking
  12. When versioning IS necessary
  13. Federation and versioning across services
  14. Common pitfalls
  15. Common interview confusions
  16. Interview angle

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:

json
{
  "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:

text
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:

json
{
  "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:

graphql
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:

graphql
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:

graphql
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:

python
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.

python
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

graphql
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:

graphql
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:

typescript
// 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.

bash
# 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:

graphql
# 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 errors array; 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 6

  • “How does GraphQL communicate errors?”errors array at the response top level (alongside data). Each error has message, 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. No v2/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.