backend / protocols / graphql / 06_errors_versioning.md

GraphQL Errors and Versioning

6 interview angles 7 min read source

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

  • “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.