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.
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.
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.
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.
Related
- Error Handling and Partial Responses in Subgraphs — parent guide
- Propagating Subgraph Errors Through the Router — the error that accompanies the null
- Retrying Failed Subgraph Fetches in Apollo Router — avoiding the null in the first place
- Entity Resolution Fallback Strategies for Partial Data — nullability at the entity boundary
- Resolving Field Type Mismatch Composition Errors — how nullability merges across subgraphs