GraphQL http status codes proper error handling guide

GraphQL http status codes proper error handling guide

GraphQL HTTP status codes work differently from REST: a GraphQL server returns HTTP 200 OK for almost every request — including ones with errors. The request reached the server and was processed; any query-level failures appear inside the JSON body in a dedicated errors array, often alongside partial data that did resolve successfully.

{
  "data": { "user": null },
  "errors": [
    {
      "message": "User not found",
      "path": ["user"],
      "extensions": { "code": "NOT_FOUND" }
    }
  ]
}

Key Takeaways

  • 200 OK ≠ success: Always inspect the errors array in the response body — not just the HTTP status — to detect failures.
  • Partial data is normal: GraphQL can return some fields successfully while others fail, all in a single 200 OK response.
  • Non-200 codes have specific meanings: 400 means unparseable query, 401 means missing auth — these happen before GraphQL execution.
  • Use extensions.code for client logic: Machine-readable error codes let you implement retries and UI updates without parsing human-readable messages.
  • Monitor the payload, not just HTTP codes: Set up alerts that inspect the GraphQL response body, or you will miss the majority of real errors.

Introduction to GraphQL error handling

When developers move from REST to GraphQL, error handling is one of the sharpest adjustments. REST uses HTTP status codes as the primary signal — 404 means resource missing, 500 means server fault. GraphQL replaces that model with a two-layer system: HTTP codes handle transport concerns, and the response body handles everything else.

This happens because GraphQL routes all operations through a single endpoint. Since the URL never changes, it cannot carry the semantic meaning that REST endpoint paths do. Instead, the response JSON carries that meaning inside data and errors fields.

AspectREST APIGraphQL
EndpointsMultiple resource URLsSingle endpoint
HTTP Status CodesSemantic (200, 404, 500)Primarily 200 OK
Error LocationHTTP status + response bodyResponse body (errors array)
Error GranularityRequest-levelField-level
Partial SuccessNot possibleBuilt-in

The partial-success model is one of GraphQL’s real strengths. Imagine a dashboard query that fetches user profile, recent orders, and notifications. If the orders service is down, a REST client gets a 503 and loses everything. A GraphQL client gets a 200 OK with the profile and notifications intact plus an error entry for orders — the page still renders meaningfully. This is why adapting your error-handling logic matters before going to production.

Understanding error handling pairs naturally with understanding GraphQL mutations — see our guide on what to return from GraphQL mutations to handle the cases where operations succeed but return no data.

GraphQL’s approach to HTTP status codes

The GraphQL specification is deliberate about this: 200 OK should be returned whenever the server successfully receives and begins executing a request, even if resolvers fail. The HTTP layer answers the question “did the request arrive and get processed?” — the errors array answers “did the query logic succeed?”

  • 200 OK covers all successfully processed requests, including those with partial or full resolver failures
  • HTTP status codes in GraphQL signal transport-level issues only, not business logic
  • The errors array in the body handles all application-level errors
  • Non-200 codes appear only when the request cannot reach GraphQL execution at all
HTTP StatusREST meaningGraphQL meaning
200 OKRequest succeededRequest processed (check errors for details)
400 Bad RequestClient input errorUnparseable or malformed GraphQL query
401 UnauthorizedAuth requiredMissing or invalid credentials (before execution)
403 ForbiddenInsufficient permissionsRequest-wide permission denial (rare in GraphQL)
405 Method Not AllowedWrong HTTP verbUsing GET for mutations or unsupported method
500 Internal Server ErrorServer faultComplete server crash, not resolver-level failure

This separation has a practical benefit for monitoring. You can configure infrastructure alerts on 5xx rates to catch server-level outages, and separately configure application-level alerts that parse the GraphQL errors array in 200 responses to track resolver failures, validation issues, and auth problems. Conflating the two leads to blind spots in production.

HTTP methods in GraphQL

GraphQL recommends POST for all operations. It supports request bodies of any size, keeps variables and sensitive data out of URLs, and works without URL-length constraints. GET can be used for simple read-only queries where caching is desirable, but mutations must always use POST — using GET for a mutation is both semantically wrong and a security risk since the URL may get logged, cached, or prefetched by browsers.

  1. Use POST for all mutation operations — always
  2. Use POST for queries with variables or complex selections
  3. Use GET only for simple, cacheable, read-only queries
  4. Verify GET queries fit within the 2,048-character URL limit of common proxies
  5. Default to POST for consistency if you do not need HTTP-level caching

When GraphQL returns non-200 status codes

Non-200 responses indicate that something stopped the request before GraphQL execution started. These are the scenarios and the correct status to use for each:

  • 400 Bad Request — query string cannot be parsed (missing braces, invalid JSON, corrupt body)
  • 401 Unauthorized — request has no credentials or the token is expired/invalid
  • 403 Forbidden — authenticated but globally blocked from this endpoint
  • 405 Method Not Allowed — used an unsupported HTTP verb
  • 413 Payload Too Large — request body exceeds server limits
  • 500 Internal Server Error — GraphQL server crashed completely

A common mistake is returning 500 for individual resolver failures. If the database is slow and one field resolver times out, that is an application-level problem — it should return 200 OK with an error in the errors array, not a 500. Reserve 500 for situations where the GraphQL server process itself is down or panicked.

/* 400 — query cannot be parsed */
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "errors": [{ "message": "Syntax Error: Expected Name, found }." }]
}

/* 200 — query parsed, but resolver failed */
HTTP/1.1 200 OK
Content-Type: application/graphql-response+json

{
  "data": { "order": null },
  "errors": [{ "message": "Database timeout", "path": ["order"] }]
}

Authorization failures are a primary source of non-200 responses. See our guide on preventing unauthorised GraphQL queries to understand how auth errors map to HTTP status codes in practice.

The GraphQL error response structure

The errors array is where GraphQL communicates application-level problems. Each item follows the structure defined by the GraphQL specification. The only required field is message; everything else is optional but valuable in production systems.

{
  "data": {
    "user": {
      "name": "Alex",
      "orders": null
    }
  },
  "errors": [
    {
      "message": "Failed to load orders: connection refused",
      "locations": [{ "line": 4, "column": 5 }],
      "path": ["user", "orders"],
      "extensions": {
        "code": "SERVICE_UNAVAILABLE",
        "retryable": true,
        "requestId": "req_8f3k2m"
      }
    }
  ]
}
FieldRequiredPurpose
messageYesHuman-readable error description
locationsNoLine and column in the query where error originated
pathNoField path in the response where resolution failed
extensionsNoCustom metadata: error codes, retry hints, request IDs

The path field is especially useful. A value of ["user", "orders", 2, "title"] tells you exactly that the title field on the third order item failed — not that “something about orders went wrong.” This precision lets clients handle failures at the right granularity rather than degrading the entire screen.

The extensions field is where well-designed APIs add machine-readable codes like "code": "NOT_FOUND" or "code": "RATE_LIMITED". Clients can act on these codes programmatically — showing a login prompt, triggering a retry after a delay, or logging the requestId for support correlation — without parsing fragile human-readable strings.

The GraphQL error structure must align with your HTTP response serialization layer. Review ResponseEntity patterns to ensure consistent error formatting across your API boundary.

Error object anatomy

Breaking down each field in a real error object helps clarify what clients should do with the information:

  1. message — display to developers in logs; sanitize before showing to end users in production
  2. locations — use during development to pinpoint the exact query line that triggered the error
  3. path — use in client logic to null-check only the failed field, not the entire response
  4. extensions.code — branch your error-handling logic here: retry on SERVICE_UNAVAILABLE, redirect on UNAUTHENTICATED
  5. extensions.requestId — log this on the client so support teams can correlate with server logs

In development environments it is reasonable to include stack traces inside extensions.stacktrace. In production, strip them entirely — stack traces expose file paths, library versions, and internal logic that attackers can exploit. The requestId gives you the correlation capability without the exposure.

Common HTTP error scenarios in GraphQL

Different errors arise at different points in the request pipeline. Knowing when an error occurs determines how it should be returned.

Error typeHTTP statusResponse locationExample
Syntax / parse error400HTTP bodyMissing closing brace in query
Schema validation200errors arrayQuerying a field that does not exist
Authentication failure401HTTP statusExpired JWT, missing Authorization header
Field-level authorization200errors arrayUser lacks permission for one field
Resolver runtime error200errors arrayDatabase timeout, third-party API failure
Server crash500HTTP statusOut of memory, process killed

The dividing line is GraphQL execution. Errors that happen before execution — parsing, auth middleware, method validation — get HTTP status codes. Errors that happen during execution — resolver failures, field authorization, data validation — get entries in the errors array with a 200 OK.

Request parsing and validation errors

GraphQL processes requests in two distinct phases before any resolver runs:

  • Parse phase: validates raw query syntax — missing braces, unmatched quotes, invalid JSON body → 400 Bad Request
  • Validation phase: checks the parsed query against your schema — unknown fields, wrong argument types, missing required variables → 200 OK with errors
  • Complexity / depth limits: implementation-dependent; most libraries return 400 for exceeded limits before execution
  • Variable type mismatch: treated as validation error → 200 OK with errors in most implementations

Validation errors are worth surfacing clearly to API consumers. A message like Cannot query field "emailAddress" on type "User". Did you mean "email"? saves significant debugging time. These appear in the errors array automatically in all major GraphQL servers and should not be suppressed in development or staging environments.

Validation failures generate specific error formats depending on your server implementation. See GraphQL validation error troubleshooting for detailed error classification and client-side handling patterns.

Authentication and authorization errors

GraphQL’s field-level resolution model makes auth more nuanced than REST. You need a clear rule for when to use an HTTP status code versus an error in the array.

  • Complete auth failure (no token, invalid token) → return 401 HTTP status before GraphQL runs
  • Valid token but restricted fields → return 200 OK with FORBIDDEN errors in the array for each restricted field
  • Avoid revealing restricted field names in error messages to prevent schema enumeration
  • Use a consistent extensions.code like "UNAUTHENTICATED" or "FORBIDDEN" across all resolvers

The industry-standard error codes from Apollo and graphql-errors libraries are UNAUTHENTICATED (needs to log in) and FORBIDDEN (logged in but not allowed). Adopting these codes makes your API predictable for client developers and compatible with standard GraphQL tooling.

{
  "data": { "viewer": { "name": "Alex", "billingInfo": null } },
  "errors": [
    {
      "message": "Not authorized to access billing information",
      "path": ["viewer", "billingInfo"],
      "extensions": { "code": "FORBIDDEN" }
    }
  ]
}

When auth checks fail, validate your error responses using Playground authorization header testing to confirm the correct status codes and error structures during development.

Server and resolver errors

Individual resolver failures should almost always return 200 OK with a descriptive error entry — not a 500. A database timeout on one field does not mean the server is down; other fields in the same query may resolve perfectly. Returning 500 here misleads monitoring systems and loses partial data that the client could have used.

Reserve 500 Internal Server Error for situations where the GraphQL server process itself cannot handle any requests: memory exhaustion, unhandled exceptions at startup, crashed worker processes. In those cases the server typically cannot produce a valid GraphQL response at all.

In production, sanitize error messages from resolvers before they reach the client. A message like Error: connect ECONNREFUSED 10.0.1.4:5432 exposes your internal network topology. Replace it with Service temporarily unavailable and log the original internally alongside the requestId from extensions.

Resolver timeouts are closely related to overall GraphQL timeout configuration. See our guide on GraphQL timeout handling to set appropriate limits and return graceful errors when resolvers exceed them.

Best practices for GraphQL error handling

  1. Always check the errors array in every 200 OK response on the client side
  2. Use machine-readable extensions.code values for client branching logic, not message strings
  3. Return 401 for missing/invalid auth before GraphQL runs; use the errors array for field-level auth failures
  4. Sanitize resolver error messages in production; include a requestId for log correlation
  5. Monitor both HTTP 5xx rates and errors-array rates in your observability stack
  6. Never return 500 for individual resolver failures — reserve it for complete server unavailability
  7. Include retryable: true/false in extensions so clients can implement safe retry logic

Client-side implementation should follow a consistent pattern: check HTTP status first (handle 4xx/5xx), then check for an errors array (handle application errors), then process data (render successfully resolved fields). Never skip the middle step just because the HTTP status is 200.

async function graphqlRequest(query, variables) {
  const res = await fetch('/graphql', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  });

  // Transport-level failure
  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${res.statusText}`);
  }

  const json = await res.json();

  // Application-level errors — still a 200 OK
  if (json.errors?.length) {
    json.errors.forEach(err => {
      console.error(`[${err.extensions?.code}] ${err.message}`, err.path);
    });
  }

  // Return both data and errors; let the caller decide
  return json;
}

For monitoring, configure your observability tools to parse response bodies, not just HTTP codes. A GraphQL API can show 100% HTTP success while 30% of queries are failing at the resolver level — invisible to any monitor watching only status codes. Platforms like Datadog, New Relic, and Grafana all support custom metric extraction from response payloads.

Proper error handling connects directly to how you load test and monitor your API under real traffic conditions. See our guides on GraphQL load testing and GraphQL monitoring to build complete production visibility.

Frequently Asked Questions

GraphQL primarily returns 200 OK for all requests that reach the execution engine — including those with resolver errors. Non-200 codes appear only for transport-level issues: 400 for unparseable queries, 401 for missing authentication, 405 for wrong HTTP method, and 500 for complete server failures. Application-level errors are always returned inside the errors array in the response body.

Because the HTTP layer and the GraphQL application layer are intentionally separated. A 200 OK means the server received and processed the request — it says nothing about whether individual field resolvers succeeded. GraphQL supports partial results, where some fields resolve correctly while others fail. Returning a non-200 status for a partial failure would discard all successfully resolved data, which is worse for clients than receiving both data and a structured errors array together.

Each entry in the errors array contains a required message string, an optional locations array with line and column numbers in the query, an optional path array identifying the exact response field that failed, and an optional extensions object for custom metadata like machine-readable error codes, retry hints, and request IDs. The extensions.code field is the most important for client-side logic.

Return 400 Bad Request when the query string itself cannot be parsed — missing braces, invalid JSON, completely malformed syntax. Return 200 OK with an errors array when the query parses successfully but fails schema validation — requesting non-existent fields, wrong argument types, or missing required variables. The dividing line is whether the GraphQL parser could understand the request at all.

Always check for the errors array after confirming the HTTP status is 200. Iterate through errors, extract extensions.code for programmatic handling, and log extensions.requestId for support correlation. Use the path field to determine which parts of your UI should show error states, while rendering the rest normally from the data field. Never assume a 200 OK means all fields resolved successfully.

It depends on scope. If the entire request is blocked because the user is authenticated but has no access to anything in the query, returning 403 Forbidden is reasonable. If the query mixes accessible and restricted fields, return 200 OK with FORBIDDEN error entries in the array for the restricted fields only — this lets the client still receive and use the accessible data. Most production GraphQL APIs prefer the latter approach for better partial-data handling.