Global Object Identification

This section documents the current GraphQL specification (October 2021), plus the working draft for @defer/@stream/@oneOf, and the GraphQL-over-HTTP specification, as published at graphql.org and spec.graphql.org — which are the references these pages are written and verified against — and, for the integration pages, against Spring for GraphQL (2.0.x), Strawberry, Ariadne, and the client docs for Apollo Client / urql / Relay. No specific patch version is pinned. Some surfaces (Apollo Router/managed federation, graphql-ws internals, the Relay compiler internals, GraalVM native) are linked rather than documented in depth.

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 Book with id: "42" and an Author with id: "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 Book means calling a book(id:) field, refetching an Author means calling a different author(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 id string 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 an id from an earlier query can still use it to refetch or normalize that object later. An id that 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 (Book, Author, …​)

Selects which underlying data source or repository to query — a Book lookup and an Author lookup are typically entirely different queries or service calls.

Type-local identifier (42)

Passed to that data source’s own lookup exactly as it would be for a type-specific field such as book(id:), since it is the same underlying primary key either way.

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 accept id`s for every `Node type, not just some. A client’s generic refetch/normalization logic assumes node(id:) works uniformly across the whole schema — a type that implements Node but that the server’s node(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 id value 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.