Debugging Missing @key Fields in Apollo Federation v2
A missing or malformed @key field in a federated graph either fails composition deterministically or, worse, passes composition and silently nulls an entity branch at runtime — this guide is the systematic workflow for finding and fixing both. It sits under Implementing Entity Resolvers with @key Directives; read that first for the contract this page debugs, and see Subgraph Implementation & Entity Resolution for how entity mapping works across the whole graph.
When to Use This Pattern
- A
rover supergraph composeorrover subgraph checkrun fails with a@key, entity, or field-type error. - A cross-subgraph field returns
nullat runtime even though composition succeeds and the data exists. - You changed a
@keyfield’s name or scalar type and downstream resolution broke.
Prerequisites
How Failures Surface
The two failure classes look completely different. Composition errors are loud and deterministic; runtime drops are silent. The diagram below maps where each one originates so you start at the right layer.
Composition-Time Diagnosis
Always begin by capturing the exact composition error against your local schemas:
rover supergraph compose --config supergraph.yaml
A missing @key on an entity referenced across subgraphs produces a deterministic message:
[ERROR] Entity type 'Product' is missing a @key directive. Ensure all entities
declare at least one @key field for cross-subgraph resolution.
If the directive exists but names a field that is not declared in the type, the message shifts:
[ERROR] Field 'productId' in @key(fields: "productId") is not defined in the
entity type 'Product'.
Each error isolates the failure to a specific subgraph, type, and field. Cross-reference it against the SDL to verify directive placement and field declarations before changing anything.
The reason to trust these messages and start from them — rather than from a guess — is that composition is deterministic. The same set of subgraph schemas always produces the same result, so a composition error is fully reproducible and points at an exact line of SDL. There is no race condition, no transient state, no environment difference to chase. That makes the composition layer the easiest place to fix a @key problem and the layer you should always exhaust first: if rover supergraph compose is green, you can rule out the entire class of structural key errors and move on to runtime with confidence. Resist the temptation to start the router and poke at queries while a composition error is still outstanding; you would only be debugging a schema that never composed in the first place.
Resolution Walkthrough
Work the failing subgraph in this order:
-
Locate the failing type. Open the subgraph named in the error and find the type it references.
-
Verify directive placement.
@keyattaches directly to the type definition in the owning subgraph. The owning subgraph defines the key field natively; an extending subgraph that references but does not own the entity declares the matching@keyand marks the field@external.extend schema @link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"]) type Product @key(fields: "id") { id: ID! name: String price: Float } -
Confirm the field exists and types align. The field named in
@key(fields: "...")must be declared in the type block with matching casing, and its scalar must be identical in every subgraph that declares the key.ID!in one subgraph andString!in another fails composition. -
Recompose. Re-run
rover supergraph compose --config supergraph.yamland confirm the error clears. -
Move to runtime. Once composition succeeds, validate actual resolution with Apollo Sandbox or a debug router run.
Successful composition does not guarantee functional resolution — you still need a __resolveReference that reads the key from the incoming reference and returns the full record.
const resolvers = {
Product: {
__resolveReference: async (
reference: { id: string },
context: { db: DB }
) => {
// reference holds the exact @key fields the router extracted
const product = await context.db.products.findById(reference.id);
if (!product) {
// throw, never return null — a null silently drops the branch
throw new Error(`Product not found for id: ${reference.id}`);
}
return product;
},
},
};
Verification Steps
Run a cross-subgraph query that forces entity resolution across a boundary:
query {
topProducts(first: 2) {
id
name
reviews { # owned by a different subgraph than Product
score
comment
}
}
}
With APOLLO_ROUTER_LOG=debug set, the trace should show this sequence:
- Subgraph A returns
[{ id: "1", name: "Laptop" }]. - The router extracts
id: "1"and dispatches an_entitiesquery to Subgraph B. - Subgraph B’s
__resolveReferencereceives{ __typename: "Product", id: "1" }. - Subgraph B returns
{ id: "1", reviews: [...] }. - The router merges both and returns the final response.
If the debug log shows the reference arriving without id, the key field is missing from Subgraph A’s response selection — the router could not build the representation. If the reference is correct but reviews is null, the resolver is returning null for a record it should find; have it throw so the failure surfaces in the trace.
Read the trace as the ordered sequence above, not as a wall of output. Each arrow in that list is a checkpoint, and a @key problem always breaks the chain at exactly one of them. A missing key at the extraction checkpoint means the bug is upstream in Subgraph A. A correct key followed by a null at the resolver checkpoint means the bug is in Subgraph B’s lookup. A correct key followed by a thrown error is the healthy case — the failure is now loud and attributable. Knowing which checkpoint broke turns “the query returns null” into a one-line diagnosis. Finally, validate against the registry before deploy:
rover subgraph check "$APOLLO_GRAPH_REF" --name products --schema ./products/schema.graphql
Common Mistakes & Gotchas
| Mistake | Root cause | Fix |
|---|---|---|
@key omitted on the base type |
Assuming the router infers identifiers | Declare @key(fields: "...") on the owning entity type |
| Scalar mismatch across subgraphs | String in one, ID in another |
Standardise on ID! for identity fields |
| Key field absent from the SDL block | Field named in the directive but never defined | Define it with matching casing and type |
@external on the owning subgraph |
Marking the owned key field @external |
Only mark @external in extending subgraphs |
The subtle one is the runtime null with green composition: it almost always traces to a __resolveReference returning undefined for a missing record, which the router treats as “branch unresolved” rather than an error. Throwing makes it visible.
A second subtle case hides in the upstream selection. The router can only build a representation from fields the owning subgraph actually returned, so if that subgraph emits records without their key field — because a projection trimmed it, or a mapper dropped it — the representation arrives at the contributing subgraph incomplete. Composition stays green because the SDL is correct; the failure is purely in the data path. This is why the debug-log step is not optional for runtime nulls: it is the only place that shows whether the reference reached the resolver with its key intact. If it did not, the fix belongs upstream in the owning subgraph’s resolver, not in the subgraph that reported the null.
Type drift is the third trap, and it is easy to introduce months after the original schema was written. Two subgraphs can each declare @key(fields: "id") and pass composition on day one, then drift when one team migrates id from ID! to String! in a refactor. Composition catches the mismatch the moment both schemas are checked together — which is exactly why the registry check belongs on every pull request rather than only on the subgraph being changed. A key field’s type is part of a contract shared by every subgraph that references the entity, and it should be changed with the same care as the key field’s name.
Frequently Asked Questions
Why does Federation v2 require explicit @key fields on entities?
Explicit keys guarantee deterministic resolution across distributed services. They remove ambiguity at composition time and give the router a reliable way to build entity representations for cross-subgraph queries, rather than inferring identity from heuristics that could differ between subgraphs.
Can I use multiple fields in one @key directive?
Yes. Composite keys use @key(fields: "id version"), and the router combines the values into a single identity tuple. Every field named in the key must be present in the representation the router builds, or resolution fails for that branch.
How do I confirm my resolver receives the right @key payload?
Start the router with APOLLO_ROUTER_LOG=debug (or use Apollo Sandbox) and inspect the _entities step. The trace shows the exact reference object passed to __resolveReference; confirm it carries every key field your SDL declares and that the resolver returns a non-null entity.