So you've shipped your first real GraphQL endpoint. The schema is clean, the resolvers are tested, the client is wired up. Then a customer hits a query that touches three nested fields, two of which work fine, and the third one, user.subscription.nextRenewal, explodes because the upstream billing service is down. Your client gets back a 200 OK. The response has a data field with most of the page. It also has an errors array with one entry. The frontend shows a blank screen.

If your first instinct is "what do you mean it returned 200 with an error in the body?", congratulations, you've found the thing that catches every team coming from REST. Once you've made peace with it, GraphQL's error model is actually quite well thought out. But it's different enough from what REST taught you that the patterns from your old codebase will hurt rather than help. Let's go through it properly.

The response shape, and what it's actually telling you

Every GraphQL response is one of three things: data only, errors only, or both. That last one, both, is the one that breaks REST-trained intuition. Here's a perfectly valid response:

JSON response.json
{
  "data": {
    "user": {
      "id": "u_42",
      "email": "ada@example.com",
      "subscription": null
    }
  },
  "errors": [
    {
      "message": "Billing service unavailable",
      "path": ["user", "subscription"],
      "locations": [{ "line": 4, "column": 5 }],
      "extensions": {
        "code": "UPSTREAM_UNAVAILABLE"
      }
    }
  ]
}

The user object is there. The email is there. The subscription failed, so its value is null and the errors array tells you exactly where in the response tree the failure happened (path: ["user", "subscription"]). This is GraphQL's whole pitch: a query is a tree of independent fields, each resolved independently, and failures in one branch shouldn't take down the whole response.

In REST, that same query would have been three endpoints, GET /user, GET /user/email, GET /user/subscription, and the third one would have returned a 503. Your client code would deal with each request separately. In GraphQL, all three are one request, and the response gives you everything that succeeded plus a clear pointer to what didn't. That's the model. Embrace it; don't fight it.

The fields inside each error object come from the GraphQL specification. They are:

Text error-object-shape
message     , required, human-readable string
locations   , optional, line/column in the query string
path        , optional, list of keys describing where in `data` the error occurred
extensions  , optional, an object you can put anything into

That's it. Four fields. Three of them are tightly defined. The fourth, extensions, is where the actually-useful information lives, and where most of the design work happens. We'll spend a lot of time there.

Why everything is 200 OK (and when it isn't)

This is the part that confuses everyone. A GraphQL response with errors in it returns 200 OK. Not 500. Not 400. Two hundred. Even if the query failed completely and data is null, the spec doesn't require any particular status code.

The reasoning is straightforward when you stare at it for a minute: HTTP status codes describe transport-level outcomes. Did the request reach the server? Did the server understand the URL? Did it manage to send a response? GraphQL operations, by contrast, are application-level, and a single request can contain multiple operations that each succeed or fail independently. A 500 would tell you "the whole thing exploded", but in GraphQL the whole thing rarely explodes; bits of it do.

The newer GraphQL-over-HTTP spec does sand down some rough edges. It says servers using the application/graphql-response+json content type may return 4xx for requests that fail at the request-parsing or validation stage, before any resolver has run. That's a sensible carve-out: if the query is syntactically broken, no field has run yet, so there's no partial data to preserve and a 400 is honest. But for resolver-level errors during execution, 200 is still the right answer.

The practical takeaway: stop using HTTP status codes to drive your client error handling. Look at response.errors and response.data instead. Apollo Client, urql, Relay, and friends already do this, most of the bugs I've seen come from teams adding their own fetch wrapper that throws on any non-2xx response and then being surprised that their error handling never runs.

The four kinds of GraphQL failure

Once you accept the model, the next thing you need is a mental categorisation, because not all errors behave the same way. Roughly:

1. Parse errors. The query string isn't valid GraphQL syntax. No fields have run yet. Response is {"errors": [...]} with no data field at all. With the response-json content type, your server is allowed to return a 4xx here.

2. Validation errors. The query parses but doesn't match the schema, referencing a field that doesn't exist, passing the wrong argument type, querying a scalar as if it were an object. Caught before any resolver runs. Same shape as parse errors: no data.

3. Resolver errors. A specific field's resolver threw or rejected. The rest of the query keeps running. data is partially populated, and the error's path tells you which field failed. This is by far the most common category in production.

4. Business-logic "errors" that aren't really errors. A user tried to add an item to a cart that's locked, a coupon code is expired, a username is already taken. These are expected outcomes of normal flows, not failures. Whether they belong in the errors array is one of the most debated questions in the GraphQL ecosystem, and the answer is "probably not". More on this in a minute.

Horizontal diagram of the four GraphQL error categories: parse, validate, execute (resolver errors with partial data), and business outcomes modeled as schema unions.

The reason categorising matters is that each category needs a different handling strategy on both server and client. Parse and validation errors mean the developer wrote a bad query and you should make them very loud in dev and silent in prod (you don't want to leak schema introspection hints in error messages to attackers). Resolver errors mean an upstream system failed and you should retry, fall back, or fail loudly depending on the field. Business-logic outcomes shouldn't even be in the errors array in the first place, they belong in the schema as proper types.

Extensions: where useful information lives

The extensions field is the one that takes GraphQL error handling from "barely usable" to "actually nice". The spec says nothing about what goes in it, it's a free-form object the server can put anything into, and the convention that has emerged is to at minimum put a machine-readable code:

JSON error-with-extensions.json
{
  "errors": [
    {
      "message": "Authentication required",
      "path": ["me"],
      "extensions": {
        "code": "UNAUTHENTICATED"
      }
    }
  ]
}

A code field is what your client switches on. message is for humans; code is for code. If you only take one thing from this article, take that: always put a code in extensions.

Apollo Server ships a small set of predefined codes that you can use as a starting point. The current ones (Apollo Server 4+) are GRAPHQL_PARSE_FAILED, GRAPHQL_VALIDATION_FAILED, BAD_USER_INPUT, PERSISTED_QUERY_NOT_FOUND, PERSISTED_QUERY_NOT_SUPPORTED, OPERATION_RESOLUTION_FAILURE, and INTERNAL_SERVER_ERROR. They're documented in the Apollo Server error reference. You don't have to use these, they're conventions, not rules, but if your stack is Apollo, sticking with the conventions makes everything talk to each other.

For everything else, define your own codes and document them next to your schema. Treat them like an enum, a finite, stable, versioned list that the client can rely on. A reasonable starter set:

Text codes.txt
UNAUTHENTICATED       , caller has no session
FORBIDDEN             , caller has a session but not the permission
NOT_FOUND             , the resource doesn't exist
BAD_USER_INPUT        , the caller sent something invalid
CONFLICT              , the operation would violate an invariant (e.g. duplicate)
RATE_LIMITED          , too many requests, try again later
UPSTREAM_UNAVAILABLE  , a dependency is down
INTERNAL_SERVER_ERROR , we don't know what went wrong

That's it. Eight codes will get most teams a long way before they need to extend. The temptation is always to add more, EMAIL_ALREADY_TAKEN, CART_LOCKED, COUPON_EXPIRED, but those aren't error codes, those are business outcomes, and we'll talk about why they don't belong here in a moment.

You can put more than the code in extensions. Common additions are a retryable: true flag, a retryAfter seconds value for rate-limit errors, a fieldErrors map for form-validation failures, or a requestId so support can correlate the error with a server log. The shape is up to you, pick one and use it consistently across every error.

Throwing the right thing on the server

Most server implementations let you throw a special error class to control what ends up in the response. In graphql-js (the reference implementation that almost everyone's Node.js stack is built on), that's GraphQLError:

TypeScript resolvers.ts
import { GraphQLError } from 'graphql';

export const resolvers = {
  Query: {
    me: (_parent, _args, ctx) => {
      if (!ctx.userId) {
        throw new GraphQLError('You must be signed in to view your profile.', {
          extensions: {
            code: 'UNAUTHENTICATED',
            // optional , surface a stable hint the client can use to
            // decide whether to redirect to /login
            retryable: false,
          },
        });
      }
      return ctx.users.findById(ctx.userId);
    },
  },
};

The thrown error is caught by the runtime, attached to the response's errors array with the correct path filled in, and data is set to null at the affected node (or wherever the non-null chain bubbles up to, more on that in a second).

In Python with Strawberry, the equivalent is raising a GraphQLError from graphql:

Python resolvers.py
from graphql import GraphQLError
import strawberry

@strawberry.type
class Query:
    @strawberry.field
    def me(self, info) -> "User":
        if not info.context["user_id"]:
            raise GraphQLError(
                "You must be signed in to view your profile.",
                extensions={"code": "UNAUTHENTICATED"},
            )
        return load_user(info.context["user_id"])

In Go with gqlgen, you don't throw, Go doesn't throw, you return an error from your resolver, and you implement a custom error presenter on the server to convert that into a GraphQL error with extensions:

Go resolver.go
func (r *queryResolver) Me(ctx context.Context) (*model.User, error) {
    userID, ok := auth.FromContext(ctx)
    if !ok {
        return nil, &gqlerror.Error{
            Message: "You must be signed in to view your profile.",
            Extensions: map[string]interface{}{
                "code": "UNAUTHENTICATED",
            },
        }
    }
    return loadUser(ctx, userID)
}

The shape on the wire is identical in all three. The mechanism for getting there is whatever's idiomatic for the language.

Bubbling and the non-null trap

Here's a thing that surprises every team eventually: if you mark a field as non-null in your schema (the trailing !), and that field's resolver throws, the error doesn't just appear at that field. It bubbles up to the nearest nullable ancestor and sets that ancestor to null. If there isn't a nullable ancestor, e.g. you've marked the whole user field as non-null, the entire data field becomes null and your client gets nothing.

Schema like this:

GraphQL schema.graphql
type Query {
  user(id: ID!): User!
}

type User {
  id: ID!
  email: String!
  displayName: String!
  avatarUrl: String  # nullable
}

If the resolver for email throws, the error bubbles up to user, which is non-null, which bubbles up to Query.user, which is also non-null. Your response is {"data": null, "errors": [...]}. One bad email field nuked the whole page.

The fix is to be honest about which fields are actually always present. A displayName that comes from a separate microservice probably isn't actually non-null in practice, sometimes that service is down, sometimes the user hasn't set one. Marking it String! because "it's always there in our DB" is making a promise you can't keep, and it costs you partial-success behaviour every time the schema gets it wrong. Default to nullable; promote to non-null only for fields where the impossibility-of-null is structural, not just usually-true.

User-facing messages vs developer messages

The message field is a string. The spec calls it "human-readable". The question is which human.

If you stuff a database error message into it, "duplicate key violates unique constraint 'users_email_idx'", the developer in dev tools will appreciate it, and the end user who happens to see it in a toast notification will be very confused. Worse, you've just leaked your schema design.

The pattern that works well: message is the safe, user-readable version. Detailed debugging goes in extensions. Something like:

JSON message-vs-extensions.json
{
  "errors": [
    {
      "message": "We couldn't find that user.",
      "path": ["user"],
      "extensions": {
        "code": "NOT_FOUND",
        "resourceType": "User",
        "resourceId": "u_42",
        "requestId": "req_abc123"
      }
    }
  ]
}

The frontend can show the message directly without sanitisation. If anything special needs to happen for a particular code, "redirect to login on UNAUTHENTICATED", "show a retry button on UPSTREAM_UNAVAILABLE", the frontend switches on extensions.code. The requestId shows up in your support form so when a customer pastes it, you can find the trace.

In production, you almost always want to scrub stack traces and database errors out of the response entirely. Apollo Server's formatError hook is where this happens:

TypeScript server.ts
import { ApolloServer } from '@apollo/server';
import { unwrapResolverError } from '@apollo/server/errors';
import { GraphQLError } from 'graphql';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  formatError: (formattedError, error) => {
    const originalError = unwrapResolverError(error);

    // If the resolver threw our own GraphQLError with a code, pass it through.
    if (formattedError.extensions?.code) {
      return formattedError;
    }

    // Otherwise it's an unexpected exception. Log it server-side, but don't
    // leak the message or stack to the client.
    console.error('Unhandled resolver error:', originalError);

    return {
      message: 'Something went wrong on our end.',
      extensions: {
        code: 'INTERNAL_SERVER_ERROR',
        requestId: getRequestId(),
      },
    };
  },
});

Two rules: any error you threw on purpose (with a code) flows through unchanged. Anything else gets logged server-side with the original detail and presented to the client as a generic INTERNAL_SERVER_ERROR with just a requestId for correlation. Your server logs have everything; your client gets nothing it doesn't need.

The mirror image of this is dev mode: in development, you absolutely do want stack traces in the response so you can debug without flipping back to the server logs. Apollo Server gates this with NODE_ENV !== 'production' by default. Don't fight it.

When errors shouldn't be in errors at all

This is the part of GraphQL error handling that the spec gives you the least guidance on, and the part where the wrong choice will haunt your codebase the longest.

Consider these "errors":

  • A coupon code the user entered has expired.
  • A username they tried to claim is already taken.
  • A cart they're trying to check out is locked because someone else is editing it.
  • A booking they tried to make conflicts with another reservation.

These are not unexpected. They are not failures of your system. They are expected outcomes of normal flows, branches in your business logic that the UI is supposed to react to. The question is: do you put them in the errors array, or do you put them in data?

If you put them in errors, you've coupled "things the user did" with "things that broke" in the same channel. Your client now has to look at every error, check its code, decide whether it's a real error or a UX branch, and react accordingly. The schema doesn't tell you that applyCoupon can fail with COUPON_EXPIRED, you have to read documentation or grep the source.

If you put them in data, you can use the schema to express the possible outcomes:

GraphQL schema.graphql
type Mutation {
  applyCoupon(code: String!): ApplyCouponResult!
}

union ApplyCouponResult =
  | CouponApplied
  | CouponExpired
  | CouponNotFound
  | CouponNotApplicableToCart

type CouponApplied {
  cart: Cart!
  discount: Money!
}

type CouponExpired {
  expiredAt: DateTime!
  message: String!  # user-facing
}

type CouponNotFound {
  message: String!
}

type CouponNotApplicableToCart {
  reason: String!
  message: String!
}

Now the client query is forced to handle each case:

GraphQL mutation.graphql
mutation Apply($code: String!) {
  applyCoupon(code: $code) {
    __typename
    ... on CouponApplied {
      cart { id total }
      discount { amount currency }
    }
    ... on CouponExpired {
      expiredAt
      message
    }
    ... on CouponNotFound {
      message
    }
    ... on CouponNotApplicableToCart {
      reason
      message
    }
  }
}

The TypeScript type generated from this query is a discriminated union on __typename. The compiler will make you handle every case. You can't forget the "coupon expired" branch because the type system knows it exists. New outcomes are added by extending the union, which is a schema change with a clear diff and an obvious migration path.

This pattern, sometimes called the "result type" pattern, sometimes "errors-as-data", sometimes the "stage-6a" pattern after a GitHub Engineering blog post that popularised it, is the single most leveraged refactor most GraphQL APIs can make. The rule of thumb: if it's a thing the user did and the UI needs to react to it, it's data, not errors. The errors array is for things the user can't control, auth failures, missing resources they're trying to read, infrastructure outages.

This isn't an absolute rule. UNAUTHENTICATED and FORBIDDEN are arguably "things the user did" (they're logged out, or they don't have permission) but they're cross-cutting enough that almost every team puts them in errors with a code, and that's fine. The line isn't perfectly sharp. But when in doubt, if you're introducing a new failure mode that the UI has to specifically branch on, reach for the result-type pattern first, not a new error code.

Handling errors on the client

Once the server is sending well-shaped responses, the client side mostly takes care of itself. The main thing to internalise is that errors and data coexist. Code that reads response.data.user.email and assumes the absence of an error means data is complete is going to crash the moment a partial-failure response comes through.

In Apollo Client, the relevant fields on a query result are data, error (the network/single-error case), and errors (the array on the response, accessible through the result extensions or through the network response depending on version). The pattern that works is roughly:

TypeScript useUser.ts
import { useQuery, gql } from '@apollo/client';

const ME_QUERY = gql`
  query Me {
    me {
      id
      email
      subscription {
        plan
        nextRenewal
      }
    }
  }
`;

export function useMe() {
  const { data, error, loading } = useQuery(ME_QUERY, {
    errorPolicy: 'all', // keep partial data even when errors are present
  });

  if (loading) return { status: 'loading' as const };

  // Hard failure , no data at all.
  if (!data?.me) {
    const code = error?.graphQLErrors?.[0]?.extensions?.code;
    if (code === 'UNAUTHENTICATED') return { status: 'unauthenticated' as const };
    return { status: 'error' as const, message: error?.message ?? 'Unknown error' };
  }

  // Partial , some fields succeeded.
  return {
    status: 'ok' as const,
    me: data.me,
    fieldErrors: error?.graphQLErrors ?? [],
  };
}

The errorPolicy: 'all' setting is the important bit. Apollo Client's default policy is none, which throws away data whenever any error is present, completely defeating GraphQL's partial-success model. Setting it to all keeps both. The cost is that you have to handle the partial case in your component, which is exactly the case GraphQL exists to enable.

For urql, the equivalent is built into the result, data and error.graphQLErrors are both on the useQuery result, and there's no destructive default. For Relay, the framework leans heavily on the result-type pattern in the schema and gives you less reason to inspect the errors array at all.

The recurring mistake on the client side is collapsing all errors into a single toast. "Something went wrong" is fine as a last resort, but if you have an UNAUTHENTICATED code in the response, the right reaction isn't a toast, it's a redirect to login. If you have a RATE_LIMITED code with a retryAfter, the right reaction isn't a toast, it's a disabled button with a countdown. Use the codes; you went to the trouble of defining them.

A handful of patterns that age well

A few things worth doing from day one, because they're easy at the start and expensive to retrofit:

Stamp every error with a requestId. Generate a UUID per request, log it server-side, include it in every error's extensions. When a customer reports a problem, the only thing you need from them is that ID, and you can pull the full trace. Without it, you're grepping logs by timestamp and email and hoping.

Keep your code list documented and stable. Add new codes deliberately. Don't let every resolver invent its own. Treat the list like a contract, clients pin to it, and removing a code is a breaking change.

Don't use INTERNAL_SERVER_ERROR as a default. If you're throwing without a code, you've left the client with no way to react usefully. Default to something, even UNKNOWN is more useful than nothing, and treat any unrecognised code as a generic fallback on the client.

Log the unwrapped error server-side. Apollo Server wraps thrown errors before passing them to formatError, which is sometimes what you want and sometimes not. Use unwrapResolverError to get back to the original, so your stack traces are useful.

Don't leak introspection in error messages. "Cannot query field 'internalAdminToken' on type 'User'" is a gift to anyone probing your API. Apollo's default behaviour in production is sensible here; if you're rolling your own error formatter, make sure validation errors are scrubbed.

Treat error rates per extensions.code as a first-class metric. If UPSTREAM_UNAVAILABLE errors are spiking, that's an SLO violation you can alert on. If BAD_USER_INPUT errors are climbing on a specific field, your UI is letting users submit something it shouldn't. Having a code on every error makes this trivially countable.

The whole thing in one paragraph

GraphQL responses can contain both data and errors. Don't fight that, design for it. Put a stable, machine-readable code in every error's extensions. Keep the message user-safe and put debugging detail in extensions too. Use HTTP 200 for resolver-level errors and read the response body to decide what happened. Bubble carefully, non-null fields are a promise, not a wish. And for the love of clean schemas, when an "error" is really just a branch in your business logic, model it as a union type in data instead of stuffing another code into errors. The result type pattern is the single biggest upgrade most GraphQL APIs can make.

Do that, and the next time a customer hits the billing-service-down path, your response will tell the truth: most of the page rendered, one field didn't, here's the code, here's the request ID, here's exactly what the client should show. No 500s. No blank screens. No "what does this error even mean?" tickets. Just a clear conversation between server and client about what worked, what didn't, and what to do about it.