Skip to main content

Class 2: Error Propagation and the Ambiguous Null

Duration: 20 minutes | Difficulty: Intermediate | Prerequisites: Class 1 of this course.

You should be comfortable reading ! markers in a schema; the introduction's chapter on schemas and types covers them if you need a refresher.


What You'll Learn​

By the end of this class, you will:

  • Trace the exact propagation rules the specification applies when a Non-Null position resolves to null
  • Predict, for any schema and any failing field, precisely how much of the response survives
  • Explain why every null in a GraphQL response is ambiguous, and how errors[].path is the only disambiguator
  • Know the official nullable-by-default guidance, the reasoning behind it, and its real costs
  • See how Relay's @required directive patches the problem from the client side, and why a patch is all it can be

What Non-Null promises​

A Non-Null type in a schema is a promise to clients: if you request this field and the response contains its parent, this position will hold a value, never null. In the Movie Database schema, Person.name: String! promises that no person object will ever arrive nameless, and directors: [Person!]! promises both that the list itself will be there and that no element of it will be null.

The promise is enforceable right up until something goes wrong. A resolver can fail for reasons unrelated to the schema: the database connection pool is exhausted, a downstream service times out, a bug corrupts a value. When that happens at a Non-Null position, the service is left without a valid value: it cannot put null there, because the type forbids it, and it cannot invent a value. The execution rules resolve the dilemma in the only remaining direction:

Since Non-Null response positions cannot be null, execution errors are propagated to be handled by the parent response position. If the parent response position may be null then it resolves to null, otherwise if it is a Non-Null type, the execution error is further propagated to its parent response position.

The error propagates upward until it finds a position that is allowed to be null, and that entire subtree is replaced by null. If nothing on the way up is nullable, the spec's last rule applies:

If every response position from the root of the request to the source of the execution error has a Non-Null type, then the "data" entry in the execution result should be null.

One field's failure has then removed every piece of data in the response.

How one error removes a whole movie​

To make the propagation rules concrete, here are the relevant Movie Database types, trimmed from the Spring GraphQL tutorial's schema to the fields this walkthrough needs, with the tutorial's movie query field renamed to movieById for this course:

type Query {
movieById(id: ID!): Movie
}

type Movie {
id: ID!
title: String!
rating: Float
directors: [Person!]!
}

type Person {
id: ID!
name: String!
}

A client asks for a movie and its directors:

query MovieWithDirectors {
movieById(id: "1") {
title
rating
directors {
name
}
}
}

Suppose resolving the first director's name raises an exception. Follow the propagation step by step, because a type in the schema dictates every hop. The error occurs at path ["movieById", "directors", 0, "name"]. That position is String!, so it cannot hold null, and the error propagates to the director object. From there, each step matches a number in the diagram below:

  1. The director element is Person! inside the list, so it cannot be null either. Here a list-specific rule from the same section applies:

    If a List type wraps a Non-Null type, and one of the response position elements of that list resolves to null, then the entire list response position must resolve to null.

    The whole directors list must become null.

  2. The directors list is [Person!]!, non-null itself, so the propagation continues to movieById.

  3. movieById has the nullable type Movie, so the climb stops there, and movieById becomes null.

In the diagram, the field that raised the error has a solid red outline. The positions the error climbs through have dashed red outlines, and the position where it stops has a green outline.

The client receives this response:

{
"errors": [
{
"message": "Name for person with ID 7 could not be fetched",
"locations": [{ "line": 6, "column": 7 }],
"path": ["movieById", "directors", 0, "name"]
}
],
"data": {
"movieById": null
}
}

The title, the rating, and any other directors all resolved successfully, yet all of those values are gone from the response. One person's missing name replaced the entire movie with null, and the client can tell what happened only because the error's path still points at the true origin. Class 1 quoted the rule responsible: the path names "the full path to the result field where the error was raised, even if that field is not present in the response".

Now rerun the thought experiment with one character changed: name: String instead of name: String!. The error stays exactly where it happened, and the response keeps everything else: data.movieById.directors[0].name is null, title and rating are intact. The type declarations between the failing field and its nearest nullable ancestor decide how far the failure spreads. Schema authors usually write those declarations to describe the data, with little thought for outages.

One more propagation rule is worth knowing because it protects the errors list from an avalanche:

That is, only one error should be added to the errors list per response position.

Propagation therefore produces a single error with a single origin path, not one error per level climbed. The working draft has already reworded this sentence: it now requires that "each error added to the errors list during execution must have a unique response path". A related proposal, PR #1184, "Sibling errors should not be added after propagation", covers errors raised in sibling positions after propagation has already removed them from the response. As of September 2026 it is at RFC stage 2 and marked as a candidate for the next stage.

Every null now means two things​

Look again at the partial response from the name: String version, the one with "name": null in it. What does that null mean? There are exactly two possibilities. Either no name is recorded for the person (a semantic null: the data point truly is absent), or the name exists and a resolver failed to produce it (an error null: the data point is unknown). The JSON at that position is byte-for-byte identical in both cases.

The only way to tell them apart is to walk the errors list and check whether some error's path matches this position. The Error Result Format section says this correlation is precisely what path is for:

This allows clients to identify whether a null resolved result is a true value or the result of an execution error.

The correlation works, but it demands that every consumer of every nullable field, in every component, in every client codebase, either perform it or accept that it sometimes shows "no name" when the truth is "name temporarily unavailable". In practice, hand-written client code often skips the correlation: it reads data, finds null, and moves on. The specification describes the two meanings only as "a true value" and "the result of an execution error". This course calls them the semantic null and the error null, the names Benjie Gillam's draft post on true nullability also uses. The @semanticNonNull directive in Class 8 uses "semantic" in the same sense: a field marked with it can be null only as an error null. The distinction comes back in Class 7, on what error nulls do to normalized caches, and in Class 8, on the spec work that finally separates them.

The official answer: nullable by default​

Given the blast-radius behavior above, the ecosystem's dominant guidance is to be sparing with !. This is the official position of graphql.org's schema-design guidance on nullability, which explains why every field is nullable by default:

This is because there are many things that can go awry in a networked service backed by databases and other services. A database could go down, an asynchronous action could fail, an exception could be thrown. Beyond simply system failures, authorization can often be granular, where individual fields within a request can have different authorization rules.

The last sentence names a subtler reason: a field you are not allowed to see has to become something, and that something is null. The guidance then states the benefit:

By defaulting every field to nullable, any of these reasons may result in just that field returned "null" rather than having a complete failure for the request.

The guidance is coherent and this course largely endorses its practical effect: fields whose resolution crosses a failure boundary (another service, a separate database, an authorization rule) should usually be nullable, so failures stay local. The Movie Database schema follows it: rating is Float, not Float!, exactly because ratings come from a separate backend that can be down while movies are fine.

The guidance also has three costs:

  • Weaker types. A field the domain always populates, like a person's name, gets declared nullable anyway, purely as an operational hedge, and every consumer now handles a null that the domain says cannot happen. Codegen produces name: string | null, and application code fills with defensive checks for a null the domain can never actually produce.
  • More ambiguity. Nullable-by-default multiplies the ambiguity from the previous section: the more nullable positions a response has, the more positions where a null requires correlation to interpret.
  • Work moved to clients. It moves the problem onto clients, and clients responded by building their own machinery, which the next section covers.

The underlying tension is this: the schema author must choose one wire behavior per field for all clients forever, and the two available behaviors are both wrong some of the time. Non-Null keeps the types strict but lets one failure remove large parts of the response; nullable keeps failures local but stops the types from documenting what can actually be absent. What a field's type ought to express (whether the data point can be absent in the domain) and what it actually controls (how far failures propagate at runtime) are two different questions forced through one syntax. That conflation is the root problem, and Class 8 covers the specification work that separates the two questions.

Relay's client-side patch: @required​

Meta's Relay client ships the clearest evidence that the tension is real: a directive whose entire job is to let each client re-decide, per fragment, what the schema decided globally. The @required directive is applied by the client to a nullable field. Relay's documentation frames the reasoning in one sentence worth quoting, because it inverts the usual assumption:

The "requiredness" of a field is actually a product decision and not a schema question.

@required(action:) offers three actions that product code chooses between. A fourth value, DANGEROUSLY_THROW_ON_SEMANTICALLY_NULLABLE_FIELD, exists mostly for migrations, and the docs strongly discourage new uses of it.

  • action: NONE: a null in the field makes Relay null out the parent linked object. This reproduces the spec's error propagation on purpose, scoped to one fragment.
  • action: LOG: the same bubbling happens, and Relay also emits a missing_required_field.log event to the client's field logger, so the team learns how often the "impossible" null actually arrives.
  • action: THROW: the component reading the field throws an exception during render, to be caught by an error boundary. The docs advise: "Only use this option if your component is contained within an error boundary."

Notice what this is: each product surface choosing its own blast radius for the same field. A notification row tolerates a missing price with NONE. A checkout screen refuses to render without it, through THROW. Per-fragment choices fit each screen better than one global schema decision, and Class 7 covers Relay's fuller error toolkit.

The directive has limits, because @required runs inside one client library, after the response arrives:

  • The wire format still carries ambiguous nulls.
  • Other clients of the same schema have to build the same handling for themselves.
  • The server still cannot express "this is never semantically absent, but errors happen".

A fix that reaches every client has to happen in the specification, and Class 8 covers the proposal that makes it.

Summary​

A Non-Null type decides how far an execution error spreads through a response. This lesson covered that rule and what it means for schemas and clients:

  • Propagation. When a Non-Null position resolves to null, the specification propagates the failure upward to the nearest nullable ancestor and nulls out everything in between. A list of non-null elements nulls out entirely if any element fails, and if the position has no nullable ancestor, data itself becomes null. The type declarations above the failing field therefore decide the blast radius.
  • The ambiguous null. Because errored positions surface as null, every null carries two possible meanings (semantic null and error null), distinguishable only by correlating errors[].path against the data, which hand-written client code rarely does.
  • Nullable by default. The official guidance is nullable by default, for solid operational reasons and at a real cost: weakened types, defensive client code, and more ambiguous nulls.
  • Relay's @required. The directive shows clients rebuilding the missing control locally (NONE, LOG, or THROW per fragment). It shows that the schema-level conflation between "can this be absent" and "how far do errors spread" is the underlying problem, and Class 8 presents the specification work that separates those two questions.

Further Reading​

What's Next​

Before fixing nullability, the course moves down one layer, to the HTTP transport that carries the response bodies from Classes 1 and 2. For years every GraphQL response shipped with status 200 OK regardless of what was inside. Class 3 covers the GraphQL-over-HTTP specification: which status codes a compliant server sends, what the new 294 status means, and why your CDN, your proxies, and your dashboards care about getting this right.