Global Object Identification
|
This section documents the current GraphQL specification (October 2021), plus the working draft for
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as GraphQL tooling iterates quickly. |
Global Object Identification is a small, widely adopted convention layered on top of the base GraphQL
specification: give every identifiable object in a schema a single, opaque, globally unique id, and expose one
root field that can fetch any such object back given only that id. This page covers the Node interface
that makes the convention possible, the opaque-ID encoding it relies on, the node(id:) root field itself, and
why clients depend on both for refetching and for normalizing their local cache.
Why identify objects globally
Schema & type system already covers the ID scalar as a
type-level building block, but the base specification says nothing about what an id value should look like
or whether it can be reused to fetch the same object back through some other field. Left unconstrained, two
problems tend to show up as a schema and its client applications grow:
-
IDs that are only unique per type. A
Bookwithid: "42"and anAuthorwithid: "42"are two different objects that happen to share an identifier — fine as long as a client always knows which type it is dealing with, but a problem the moment a client wants one generic way to say "give me back this object" without first knowing its type. -
No single field to refetch an arbitrary object. Without a shared convention, refetching a
Bookmeans calling abook(id:)field, refetching anAuthormeans calling a differentauthor(id:)field, and a client’s generic refetch/cache-update logic has to special-case every type in the schema instead of calling one field the same way for all of them.
Global Object Identification solves both by committing to two rules: every identifiable type implements a
shared Node interface exposing a globally unique id, and the schema exposes exactly one root field,
node(id:), that resolves any such id back to the object it identifies, regardless of type. Relay — the
GraphQL client that popularized this pattern — documents the full convention as the
Relay Global Object Identification specification, and
graphql.org’s own walkthrough at
graphql.org/learn/global-object-identification covers
the same ground from the schema-author’s side.
The Node interface
Any type whose instances should be individually refetchable implements Node, which requires nothing more than
a non-null id field:
interface Node {
id: ID!
}
type Book implements Node {
id: ID!
title: String!
publishedYear: Int!
author: Author!
}
type Author implements Node {
id: ID!
name: String!
books: [Book!]!
}
Interfaces, unions & inputs covers interface mechanics
in general — how a query selects fields common to every implementer, and how … on Book inline fragments
select type-specific fields. Node is simply the one interface, by convention, that almost every identifiable
type in a schema ends up implementing, alongside whatever domain-specific interfaces the schema already defines.
Not every type needs it: a mutation payload, an embedded value type with no independent identity (an Address
that only ever appears nested inside an Order), or a purely computed aggregate has no reason to be
individually refetchable, and skipping Node for those types is the normal, expected choice rather than an
oversight.
Opaque, globally unique IDs
The id a Node-implementing type returns must be unique across the entire schema, not just among instances
of its own type — a Book’s `id and an Author’s `id must never collide, because node(id:) has to be able
to tell, from the id alone, which type and which underlying record to resolve. The conventional way to
guarantee that is to encode both the type name and the type-local identifier into one opaque, base64-encoded
string, rather than exposing either a bare database primary key or a bare UUID on its own:
# The convention: base64("<TypeName>:<type-local id>")
echo -n "Book:42" | base64
# => Qm9vazo0Mg==
The server decodes that same string back into its two parts (Book, 42) whenever it needs to resolve an
id — in a Node type’s own field resolver, and in node(id:) itself:
type Book implements Node {
id: ID! # resolves to base64("Book:" + the book's internal primary key)
title: String!
}
Two properties matter more than the exact encoding chosen:
-
Opacity. A client must treat the
idstring as a value to pass back to the server unchanged, never as something to parse, decode, or construct itself. Nothing in the specification requires base64 specifically — it is simply a convenient, self-describing, and reasonably compact encoding — but whatever a schema picks, clients must never depend on its internal structure. Changing the encoding later (a different delimiter, a different internal primary-key format) is only safe to do freely as long as no client has started parsing the string instead of treating it as opaque. -
Stability. The same underlying object should always encode to the same
id, for as long as that object exists, so a client that stored anidfrom an earlier query can still use it to refetch or normalize that object later. Anidthat changes across requests for the same object defeats both refetching and cache normalization described below.
The node(id:) root field
The schema exposes exactly one field for resolving an opaque id back into the object it names, typed to
return the Node interface itself so it can resolve to any implementing type:
type Query {
node(id: ID!): Node
books: [Book!]!
authors: [Author!]!
}
A client that already holds an opaque id — from an earlier query’s id field, or from a mutation payload — calls node(id:) and uses an inline fragment to select the type-specific fields it wants, exactly as described
in Interfaces, unions & inputs:
query RefetchBook($id: ID!) {
node(id: $id) {
id
__typename
... on Book {
title
publishedYear
author {
name
}
}
}
}
Given \{ "id": "Qm9vazo0Mg==" } as the variables, the server decodes the id, determines it names a Book,
resolves that book, and returns a response shaped by the inline fragment that matched:
{
"data": {
"node": {
"id": "Qm9vazo0Mg==",
"__typename": "Book",
"title": "The Left Hand of Darkness",
"publishedYear": 1969,
"author": {
"name": "Ursula K. Le Guin"
}
}
}
}
Requesting __typename alongside id is the normal pattern here — it tells the client which inline fragment
actually matched without inspecting the opaque id string itself, which per the rule above it should never
parse.
On the wire, RefetchBook is a request like any other GraphQL operation — Serving over HTTP covers the envelope in full, but the shape is
worth seeing once here since node(id:) is the field every one of those requests eventually funnels through:
curl https://api.example.com/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"query RefetchBook($id: ID!) { node(id: $id) { id __typename ... on Book { title } } }","variables":{"id":"Qm9vazo0Mg=="}}'
Resolving node(id:) by decoded type
A single node(id:) resolver has to dispatch to the correct underlying lookup once it has decoded an opaque
id, because the field is typed to return the Node interface rather than any one concrete type. The decode
step recovers exactly the two pieces of information encoded earlier — which type, and which type-local
identifier — and the resolver dispatches on the first to look up the second:
Decoded from the id |
What the resolver does with it |
|---|---|
Type name ( |
Selects which underlying data source or repository to query — a
|
Type-local identifier ( |
Passed to that data source’s own lookup exactly as it would be for a
type-specific field such as |
Because the dispatch only needs the decoded type name, adding a new Node-implementing type to a schema means
adding one more branch to this same dispatch logic — the node(id:) field’s own signature in the schema never
changes. A resolver that forgets to add that branch for a new type is the most common way node(id:) silently
stops working for one type while continuing to work for every other one, which is why it is worth testing
node(id:) against every Node-implementing type directly rather than assuming it inherits correctness from
each type’s own dedicated field resolver.
Refetching after a mutation
Mutations already covers the <MutationName>Payload convention — a
mutation returns a payload object wrapping the affected object rather than the bare type. Including that
object’s id field in the payload’s selection set is what lets a client refetch or re-synchronize it later
through node(id:), without the server having to expose any additional purpose-built refetch field:
mutation UpdateBookTitle($input: UpdateBookTitleInput!) {
updateBookTitle(input: $input) {
book {
id
title
}
}
}
A client holding that returned id can later issue the exact RefetchBook query shown above — against the
same node(id:) field every other Node-implementing type in the schema also resolves through — to pull the
book’s current state again, rather than needing a book(id:)-shaped field that only works for books.
Cache normalization
The other half of why clients rely on globally unique IDs, beyond explicit refetching, is cache normalization.
A GraphQL client such as Apollo Client or Relay does not cache a query’s JSON response as one opaque blob;
instead, it flattens every object in the response into a flat store keyed by a normalized identifier — commonly
the pair \{ __typename, id } — so that the same underlying object, returned by two entirely different
queries, is stored once and kept in sync everywhere it appears on screen.
Concretely, a books list query and the RefetchBook query above can both return the same book:
{ "__typename": "Book", "id": "Qm9vazo0Mg==", "title": "The Left Hand of Darkness" }
Because both responses carry the identical \{ __typename, id } pair, a normalizing client recognizes them as
the same cache entry and merges the two results into one record rather than keeping two independent, possibly
diverging copies — so a mutation that updates the book’s title through the payload shown above is reflected
immediately in every place that book is displayed, including the list view, without an extra round trip.
Client libraries overview and
Apollo Client configuration cover how a specific client
library configures and customizes this normalization; Global Object Identification’s contribution is making
that normalization possible at all, by guaranteeing every object carries a stable, globally unique identifier to
normalize on in the first place.
Practical considerations
-
node(id:)must acceptid`s for every `Nodetype, not just some. A client’s generic refetch/normalization logic assumesnode(id:)works uniformly across the whole schema — a type that implementsNodebut that the server’snode(id:)resolver forgets to handle silently breaks refetching for that type alone, which is easy to miss in testing if nothing exercises it directly. -
Treat the opaque encoding as an internal implementation detail, not a public contract. Even though the base64 scheme above is a widely followed convention, nothing obligates a client to be able to decode it, and a server is free to change what it puts inside the encoded string as long as it stays stable per object and the client never depends on its internal shape.
-
Global uniqueness is schema-wide, not per-request. The same
idvalue must always identify the same object no matter which query produced it — generating `id`s from something request-scoped (a result’s position in a paginated list, for instance) breaks both refetching and cache normalization the moment that position changes across requests.
See Pagination for the related, but distinct, opaque-cursor convention
used by Relay-style connections — a per-list-position cursor for paging through results, not a per-object
identifier for refetching a single object — and Caching for how these
stable global IDs also factor into server-side response caching.