Skip to content
advanced Phase 11 · Error Handling, Nullability, and Schema Evolution

Null Propagation and Bubble-up Rules

How non-null violations propagate nulls up the response tree in GraphQL.

50m
2 problems
Topic Progress 0%

One Missing Author, One Empty Screen

One Missing Author, One Empty Screen

The schema looks reasonable enough that nobody argues about it in review:

type Query {
  post(id: ID!): Post!
}
type Post {
  id: ID!
  title: String!
  author: Author!        # every post has an author, right?
}
type Author {
  id: ID!
  name: String!
}

Then someone soft-deletes an account. The join returns null, the resolver returns null (or throws), and the client receives this:

{
  "data": null,
  "errors": [
    {
      "message": "Cannot return null for non-nullable field Post.author.",
      "path": ["post", "author"]
    }
  ]
}

Not "author missing" — everything missing. The leaf failure climbed three declarations and erased the response. A row being deleted in one table turned into a blank screen for a query that also asked for title, which resolved perfectly.

This is the behavior the specification calls null propagation, and it is deterministic: a null produced where the schema says null is impossible gets passed upward until it lands somewhere the schema allows null, or until it reaches the root and takes data with it. Nothing about the failure is mysterious, which means you can predict it exactly — and you should, because every ! you type is a promise that a future data state has to keep.

The design question this topic exists to answer: where do you place that promise? Mark everything non-null and one bad join blanks the screen; mark everything nullable and every client drowns in null checks. The boundary between them is a deliberate engineering decision about which failures users are allowed to see.

How the Null Actually Travels

How the Null Actually Travels

The rule in one sentence: when a field produces null but its type is non-null, the null is handed to the parent field, and the process repeats until it reaches a nullable field — or the response root.

Trace it against the schema above. The resolver for author returns null; author: Author! forbids it, so an error is recorded with path: ["post", "author"] (the position of the failing field, not where the null stopped) and post's value becomes null. But post: Post! forbids that too, so the null climbs once more, to the root. data accepts null, the climb ends:

Query.post: Post!              <- null not allowed: climbed past
 └─ Post.author: Author!       <- resolver returned null (error raised HERE)
     └─ Author.name: String!   <- never ran; its branch is discarded
Schema at each level Where the null stops Client receives
post: Post!, author: Author! the response root data: null + 1 error
post: Post!, author: Author author data: { post: { author: null } } + 1 error (a throw still raises one)
post: Post, author: Author! post data: { post: null } + 1 error
posts: [Post!]!, one item nulls the response root data: null + 1 error
posts: [Post], one item nulls that list index the other posts survive, index is null

Three details separate people who have read the spec from people who have only used it:

  1. A null on a nullable field is data, not an error. If author: Author and the resolver simply returns null, no entry appears in errors at all. Errors arise only when a resolver throws or produces null for a non-null field. "The field is null" and "the field failed" are different statements.
  2. Independent branches keep their data. Execution finishes unrelated selections before the response is assembled, so a failure under post does not touch a sibling top-level field. The blast radius is the ancestor chain of the failing field — nothing wider — unless that chain itself reaches the root.
  3. List indices are positions, not names. With posts: [Post!]!, the first item's failure propagates through the list container: an element cannot be null, so posts itself becomes null, and then whatever type posts has decides the next hop.

Because the algorithm is pure bookkeeping over the type tree, a trace exercise is mechanical: start at the failing resolver, look at the declared type, and repeat until you hit a nullable declaration or data.

Drawing the Nullability Boundary on Purpose

Drawing the Nullability Boundary on Purpose

Each ! is a claim about the future. Write them where the data layer guarantees the claim, and leave them off where reality can break it.

What non-null should mean: the value comes from a single row with a NOT NULL constraint, an identifier the server itself minted, or a computation that cannot fail. Post.id, Post.title (if the column is required), a count computed from an in-memory list — those promises hold.

What belongs in nullable territory:

  • Joins and lookups that reference soft-deletable rows — exactly the author case.
  • Anything served by another team, another service, or a federation boundary where the remote may answer late or not at all.
  • Computed or user-generated content: a derived ranking, an oEmbed expansion, a profile headline that may not exist yet.
  • List items whose individual failure should not cost the whole list.

Nullability of list members deserves its own decision, because the two dimensions fail differently:

Annotation Semantics When a member fails
[Post!]! list always present, members guaranteed whole list nulls — strongest promise, largest blast radius
[Post!] list may be absent, members guaranteed the null still nulls the list, but a nullable parent can absorb it
[Post]! list present, members may be null only that index nulls; siblings and the rest survive
[Post] both may be absent maximum tolerance, maximum client-side null handling

Two operational habits complete the picture:

Make failures loud. Returning null for a non-null field is already an error — the server records one — but a bare "cannot return null" tells an on-call engineer nothing about which resolver or which row. Throwing a GraphQLError carrying a code and a correlation id converts the same situation into something diagnosable. Reserve the bare return for cases where you genuinely do not know more.

Test the promise. A schema assertion like author: Author! is an invariant; invariants deserve a test that simulates the missing row and asserts the response shape you predicted. Teams that write these tests discover their accidental non-null chains during CI instead of during an incident.

Interviewers ask this question almost verbatim: "what happens when a non-null field returns null?" The complete answer names the algorithm (propagate to the nearest nullable ancestor), the endpoint (or data: null at the root), the side conditions (sibling branches unaffected; a nullable null raises no error), and the design lever (moving the ! one level down changes the blast radius from the whole response to one subtree).

Practice Problems

0 / 2 solved
Trace a Null Propagation Failure
Null bubbling trace

Given the schema and resolver outcomes below, predict the exact response body: data value, number of errors, and each error path. Then state which single annotation change would limit the damage to the author field.

Example:

Input: Query.feed: [Post!]! ; Post.author: Author! ; author resolver for index 1 returns null; other resolvers fine

Output: data: null, one error, path ["feed",1,"author"] — the null leaves the item (item type non-null), nulls the list (container non-null), then the root

Both list dimensions are non-null, so there is no place for the null to rest until data itself.

Optimal Solution — O(d) time, O(d) space

Walk upward from the resolver: compare the produced null against the declared type at each level and continue while the declaration is non-null. The walk ends at the first nullable declaration, or at data when every hop is non-null. Record the error once, at the field that produced the null.

// Schema: Query.feed: [Post!]! , Post.author: Author!
// author resolver for feed[1] returns null

const predicted = {
  data: null, // item type ! => list becomes null; list type ! => root data null
  errors: [
    {
      message: 'Cannot return null for non-nullable field Post.author.',
      path: ['feed', 1, 'author'], // failing position, not the stop position
      locations: [{ line: 5, column: 5 }],
    },
  ],
};

// Limiting the blast radius to that one element:
//   author: Author        -> feed[1].author = null, siblings intact (error still raised,
//                            because the resolver violated nothing: nullable null is data)
// or keep author: Author! and relax the list: posts: [Post] -> index 1 nulls, list survives

Edge Cases:

  • Failure on the root field itself (no ancestors to absorb it)
  • Two failing items in one list (one error entry per failure, single bubbled data)
  • Resolver throws instead of returning null (identical propagation, message differs)
Redesign Nullability for Partial Results
Schema fault tolerance

The product requirement: the feed must still render every post whose author lookup fails, and a failure in one post must never blank the feed. Rewrite the nullability annotations to meet it, and document what each change costs the client.

Example:

Input: type Query { feed: [Post!]! } type Post { id: ID! title: String! author: Author! } (author lookup can miss)

Output: type Query { feed: [Post!]! } type Post { id: ID! title: String! author: Author } — one failed lookup yields a null slot in one post; the list and every other field survive

Relaxing only the unreliable edge contains the null at the exact place reality can produce it, while id and title keep their non-null guarantees.

Optimal Solution — O(d) time, O(1) space

Identify each edge whose backing data can be absent or late, relax exactly those to nullable, and keep non-null on values sourced from required columns. The failure then stops at the first relaxed declaration, which by construction sits where the client can render a fallback.

# before — one soft-deleted author blanks the whole feed
type Query {
  feed: [Post!]!
}
type Post {
  id: ID!
  title: String!
  author: Author!
}
type Author {
  id: ID!
  name: String!
}

# after — promise only what the row guarantees
type Query {
  feed: [Post!]!            # the query itself always answers a list
}
type Post {
  id: ID!                   # PK: cannot be null
  title: String!            # NOT NULL column
  author: Author            # lookup can miss: null stops HERE
}
type Author {
  id: ID!
  name: String!             # present whenever the author row exists
}

# client contract this creates:
#   post.author === null -> render "Author unavailable" placeholder
#   no other field is affected; the list never disappears

Edge Cases:

  • Author row exists but a nested non-null field fails (propagation resumes at that level)
  • Entire author service times out: decide whether author becomes null-per-post or the query should fail wholesale
  • A client that typed author as non-null in generated code must be regenerated after the change (see schema evolution rules)

Quiz

1. Schema: Query.post: Post!, Post.author: Author!. The author resolver returns null. What is the response?

Question 1 options

2. A resolver returns null for a field declared as nullable. What appears in the response?

Question 2 options

3. Which list annotation localizes a failure to a single element?

Question 3 options

4. Query has two root fields: post: Post! and stats: Stats!. post.author fails as before. What does data contain?

Question 4 options

Flashcards

Question

Null propagation rule

Answer

Null where the type forbids it passes to the parent field and repeats until a nullable field or the response root absorbs it.

Question

Nullable null vs error null

Answer

A nullable field returning null is plain data with no errors entry; a non-null field producing null raises an error and bubbles.

Question

Error path during bubbling

Answer

path names the field where the failure occurred (e.g. ["post","author"]), not the ancestor where the null stopped.

Question

List member nullability

Answer

[Post] nulls one index on failure; [Post!] nulls the whole list. The list container nullability decides only the next hop.

Question

Blast radius of a non-null chain

Answer

An unbroken !-chain from a leaf to the root turns one leaf failure into data: null for the entire response.

Question

Sibling branches during a failure

Answer

Independent selections still execute; their values survive only if the failure stops before reaching their common ancestor.

Revision Notes

Key Takeaways

  • 1. Propagate to the nearest nullable ancestor, or to data: null if the chain reaches the root
  • 2. null on a nullable field raises no error — "is null" and "failed" are different states
  • 3. Every ! is a durability promise; place them only where the data layer guarantees them
  • 4. Nullable list members localize failure to one index; nullable containers only move the stop point

Interview Tips

  • • Answer the "non-null field returns null" question in four parts: the rule, where it stops, what siblings do, and how you would change the schema
  • • Walk a trace by pointing at declarations: failing field, declared type, parent, declared type, until you hit nullable or root
  • • Defend a concrete nullability choice — e.g. author: Author for soft-deletable rows — as blast-radius management, not indecision

Cheat Sheet

Null Propagation Cheat Sheet

Rule: null where ! forbids it -> hand to parent -> repeat -> stops at nullable field or at data: null.

Query.post: Post!             <- climbed past
 └─ Post.author: Author!      <- resolver returned null (error HERE, path here)
     └─ Author.name: String!  <- branch discarded, never ran
Declaration Failure stops at Client sees
author: Author! (post Post!) root data: null + 1 error
author: Author author author: null, error only if resolver threw
[Post!]! one item fails root whole list lost
[Post] one item fails that index siblings survive
  • Errors arise only on non-null violations or throws — a nullable null is silent data
  • Error path = failing field position, not the stop point
  • Promise only what the data layer guarantees; every ! widens blast radius
  • Prefer throwing GraphQLError with a code over a silent null for diagnosability

Continue Learning