Handling Nullability and Partial Data in Federated Queries

Nullability is the most consequential decision in a federated schema and the one most often made by reflex. Every ! is a promise that a value will always exist, and in a distributed graph that promise usually depends on a service another team operates. This page covers exactly how null propagation works, how to choose nullability deliberately, and how clients should consume the partial responses that result. It sits under Error Handling and Partial Responses in Subgraphs.

When to use this pattern

  • Use it when writing any field resolved by a different subgraph, which is the moment the promise stops being yours to keep.
  • Use it when auditing an existing schema, because most graphs accumulate non-null declarations that were never examined.
  • Skip the audit only for fields a subgraph resolves entirely from its own data, where non-null is honest and cheap.

Prerequisites

The propagation rule, precisely

The rule itself is short. When a field errors, the executor tries to write null into that position. If the field’s type is nullable, the null is written and execution continues. If it is non-null, null cannot be written there, so the executor discards the parent object and tries again one level up. This repeats until it reaches a nullable position, or until it reaches data, which becomes null.

Two consequences follow that surprise people.

A non-null list element is a hazard multiplier. [Line!]! means one failed line destroys the entire list, not just that element. [Line]! keeps the list and nulls the failed element, which is almost always what a UI actually wants for a collection.

Nullability is transitive across teams. The blast radius of your subgraph’s failure is decided by declarations in schemas you do not own — the list wrapper on the field that contains your entity, the nullability of the root field above it. Nobody can see this coupling in a single subgraph’s SDL.

One failed element, three different outcomes With a list of non-null elements the whole list is lost. With a nullable element type only that element is null. With a nullable list the failure can also empty the list, which is rarely what is wanted. lines[7] fails — what the client receives [Line!]! lines: gone entirely and the null continues up into the order itself 39 good lines discarded almost never what you want [Line]! lines: 39 values + 1 null the list survives, one slot is empty one gap, everything else intact usually the right choice [Line!] lines: null the list is absent, but the order survives "no lines" and "lines unknown" become indistinguishable The middle column is the default worth adopting for collections resolved across a boundary.

Implementation walkthrough

The change is a schema change, and it is best expressed as a rule rather than a case-by-case judgement.

# orders subgraph
type Order @key(fields: "id") {
  id: ID!                    # ours, always exists — non-null is honest
  status: OrderStatus!       # ours — non-null

  # A collection resolved partly by another subgraph: keep the list, allow gaps.
  lines: [OrderLine]!

  # Computed by the pricing subgraph. Their availability is not our promise.
  total: Money

  # A reference to an entity another team owns and may delete.
  customer: Customer
}

Then encode the rule so it does not depend on whoever reviews the next pull request:

// eslint-plugin-graphql rule, or an equivalent in your schema linter
// Any field whose type is owned by another subgraph must be nullable, and any
// list of such a type must have a nullable element type.
module.exports = {
  create(context) {
    return {
      FieldDefinition(node) {
        if (!isCrossSubgraph(node)) return;
        if (isNonNull(node.type)) {
          context.report({ node, message: "cross-subgraph field must be nullable" });
        }
        if (isListOfNonNull(node.type)) {
          context.report({ node, message: "use [T]! so one failure does not empty the list" });
        }
      },
    };
  },
};

Clients then have to be written for the resulting shape, which is the half teams forget:

// A null here is normal, not exceptional. Render a placeholder, not an error page.
function OrderTotal({ order }: { order: Order }) {
  if (order.total == null) return <Placeholder label="Total unavailable" />;
  return <Money value={order.total} />;
}

Verification steps

Nullability behaviour is only observable against the composed graph, so test there.

# Force the pricing subgraph to fail, then run the real operation.
# Assert on shape rather than on values.
curl -s localhost:4000/graphql -H 'content-type: application/json' \
  -d '{"query":"{ orders(first:5){ id status total } }"}' \
  | jq '{orders: (.data.orders | length), errors: (.errors | length)}'
# expect: { "orders": 5, "errors": 5 } — not { "orders": null, ... }

That single assertion is the whole test: five orders present, five errors, no data lost. If orders is null, a non-null chain exists somewhere between the failing field and the root, and the fix is one exclamation mark.

Write it as a snapshot of the response shape and keep it in CI. Its value is not in catching today’s bug but in failing the day somebody tightens a nullability declaration two subgraphs away, which is a change no composition check will flag and no reviewer of that pull request could reasonably connect to your page.

Then test the client against that shape. A UI that throws on a null it did not expect converts a graceful degradation back into an outage, and this is where that gets discovered cheaply.

Two questions decide every nullability choice If the field is owned locally and always exists, non-null is honest. If it comes from another subgraph, nullable. If the parent is meaningless without it, non-null is acceptable but the parent should then be nullable. Owned locally? Parent meaningful without it? the field declare reasoning local, always present T! a promise you can keep resolved by another subgraph T their uptime, not yours a collection across a boundary [T]! one gap, not an empty page parent meaningless without it T!, parent nullable stop the null one level up

It is worth being explicit about why this decision is so easy to get wrong: nullability is written by one team, and its consequences are experienced by another. The author of total: Money! is making a statement about the pricing service’s availability, and they are usually not on the pricing team, do not see its error budget, and will not be paged when it degrades. Nothing in the review of that line surfaces any of that context.

The practical countermeasure is to change what the reviewer is asked. “Is this field always present?” invites optimism, because in every test environment it is. “Whose outage does this exclamation mark commit us to surviving?” invites the correct answer, because the honest response is almost always “somebody else’s”. Teams that adopt the second phrasing tend to arrive at nullable cross-subgraph fields without needing a rule at all, which is a better outcome than a lint error nobody understands.

Common mistakes & gotchas

Using [T!]! for anything resolved across a boundary. It is the default most schema authors reach for and the wrapper with the largest blast radius. One failed element takes the whole collection.

Treating null as an error in the client. In a federated graph null is a routine outcome. A client that renders an error page on any null converts every partial degradation into a total one.

Tightening nullability to “improve” a schema. Adding a ! looks like a strengthening and is a change to failure behaviour across every operation that touches the field. It deserves the same scrutiny as removing a field.

Auditing an existing schema is a bounded exercise rather than an open-ended one, because only four patterns actually matter and all four are greppable. Working through them in order tends to remove most of a graph’s accidental fragility in an afternoon.

Four things to grep for Lists of non-null elements across a boundary, non-null cross-subgraph fields, unbroken non-null chains to the root, and clients that treat an expected null as an error. Auditing an existing schema what to look for how common fix [T!]! across a boundary very change to [T]! non-null cross-subgraph field very drop the ! non-null chain to a root field occasional make one link nullable nullable field, client throws on null common fix the client, not the schema The fourth row is the one that lives in client code, and it is the one most often blamed on the schema. Three of the four fixes are a single character; the fourth is a rendering decision.

Frequently Asked Questions

Is non-null ever right for a cross-subgraph field?

Occasionally, when the parent genuinely has no meaning without it — an OrderLine without its product reference is arguably not a line. Even then the better shape is usually a non-null field inside a nullable parent, so the null stops at the line rather than the order. The goal is not to avoid non-null but to control where propagation stops.

Does making things nullable weaken the schema?

It weakens the guarantee and strengthens the system, and in a distributed graph the guarantee was mostly fictional anyway. A non-null field that nulls its whole parent during a routine deploy is not providing certainty; it is providing an outage. Nullable with an error entry is a more honest description of what a distributed system can promise.

How do clients tell “empty” from “failed”?

By the presence of an error entry at that path. Null with no error means genuinely absent; null with an error means unknown. This is why clients should iterate errors and index by path rather than only inspecting data — the two together carry the meaning, and either alone is ambiguous.

Can codegen help?

Considerably. Generated types make nullability visible at the call site, so a developer reading total: Money | null has to handle the case rather than discover it at runtime. Teams that adopt strict nullability without codegen tend to write clients that crash on the nulls they asked for.

What about @defer?

It changes when data arrives, not whether it is null. A deferred fragment that fails still follows the same propagation rules within its own payload, and the client still needs the same handling. Deferring is a latency tool, not a resilience one, and treating it as the latter leads to schemas where the critical path is still one non-null chain from a blank page.