Skip to content

Keys from the schema

GraphQL only For plain HTTP, name keys in the Surrogate-Key header on your origin's response.

Your schema already describes which fields identify an object. The @surrogateKey directive lets you state the rest there too: which fields form a key, which fixed keys a type or field carries, and where the automatic key rules should stay out. GraphPilot reads it when it builds a deployment, so the keys change with the schema and there is no second place to keep in sync.

If your schema already marks keys with another directive, such as @key from Apollo Federation, you can configure GraphPilot to read that one instead.

A surrogate key decides which cached responses one purge removes together. Start from that question, and the place to write the directive follows:

A purge should reachDeclareKey
One object, wherever it appearsNothing, or a key field: id: ID! @surrogateKeyProduct:id:p01
Objects that share values@surrogateKey(of: "category.id") on the typeProduct:category.id:audio
Every implementor or member at onceThe key on the interface or unionMedium:genre:fiction
What one field of a parent returns@surrogateKey(of: "id") on the fieldCategory:featured.id:p01
A group you name by hand@surrogateKey(static: "catalogue")catalogue

Each directive defines exactly one key. That key can be based on a single field or on a combination of fields. To give the same type more than one key, repeat the directive; it is repeatable.

The first four are built from values in the response, so they are entity keys: a mutation whose response carries the same key purges them automatically. A static key is only ever purged when you name it.

A type with an id: ID! field is keyed Type:id:<id> without anything written in the schema, by the two automatic rules for key scalars and key names. Where one of them picks a field that identifies nothing, switch it off with ignore.

On a field that returns a scalar or an enum, the directive makes that field a key field of the enclosing type. static is allowed there and attaches a fixed key that is included only when the field is queried; of has no effect on such a field and must not be used.

schema.graphql
type Product {
id: ID! @surrogateKey # → Product:id:{id}
name: String
brand: String! @surrogateKey # → Product:brand:{brand}
}

Annotating a scalar makes every field of that type a key field of the object it sits on. It does the same as listing the scalar in the configuration, but keeps it in your schema. An enum type works the same way.

schema.graphql
scalar SKU @surrogateKey # Every field returning this type becomes a key field
type Product {
sku: SKU! # → Product:sku:{sku}
name: String
category: Category!
}
type Variant {
parentSku: SKU! # → Variant:parentSku:{parentSku}
color: String
}
schema.graphql
enum StockStatus @surrogateKey {
# Every field returning this type becomes a key field
IN_STOCK
SOLD_OUT
}
type Product {
stockStatus: StockStatus! # → Product:stockStatus:{stockStatus}
name: String
}
type Inventory {
status: StockStatus! # → Inventory:status:{status}
available: Int
}

A key built from several fields is a composite key: one key per combination of values. A type can carry single-field keys and composite keys side by side, one directive each:

schema.graphql
# Single-field keys
type Product {
id: String! @surrogateKey # → Product:id:{id}
sku: String! @surrogateKey # → Product:sku:{sku}
}
# Composite keys (multiple fields)
type PriceList
@surrogateKey(of: "region currency") # → PriceList:currency:{currency}:region:{region}
@surrogateKey(of: "channel region") # → PriceList:channel:{channel}:region:{region}
{
region: String!
currency: String!
channel: String!
}

The fields of a composite key are sorted alphabetically by name, so region currency and currency region produce the same key. When one of them is a list, the key multiplies out into one key per combination: @surrogateKey(of: "brand tags") with brand: "acme" and tags: ["new", "bestseller"] yields Product:brand:acme:tags:new and Product:brand:acme:tags:bestseller.

On an object type the directive needs at least one argument, of, static or both. A bare @surrogateKey on an object does not know how to build a key, and none is created. of can reach into a related type; selection set expansion fetches those fields when the client did not select them.

schema.graphql
type Product
@surrogateKey(
of: "brand category_id" # → Product:brand:{brand}:category_id:{category_id}
)
{
brand: String!
category_id: ID! # → Product:category_id:{category_id}, automatic: an ID field is a key field
}

This key reaches products that share both brand and category, never products that share only one of them. category_id is an ID, so the automatic rule for key scalars also keys Product:category_id:{category_id} on its own; a foreign key of another scalar type gets no such key. For a purge by brand alone or by category alone, declare those keys as well:

schema.graphql
type Product
@surrogateKey(
of: "brand category_id" # → Product:brand:{brand}:category_id:{category_id}
)
{
brand: String! @surrogateKey # → Product:brand:{brand}
category_id: ID! @surrogateKey # → Product:category_id:{category_id}
}
Use caseSurrogate key
Products of a specific brand and categoryProduct:brand:{brand}:category_id:{category_id}
Products of a specific categoryProduct:category_id:{category_id}
Products of a specific brandProduct:brand:{brand}

These examples use a foreign key field, category_id. Traversing the relation instead (category.id) makes it clear that the key follows the relationship rather than a raw identifier, and it needs no extra field on the type:

schema.graphql
type Product
@surrogateKey(
of: "brand category.id" # → Product:brand:{brand}:category.id:{category.id}
)
{
brand: String!
category: Category!
}
type Category {
id: String!
name: String
}

The same with separate keys for the brand and the category:

schema.graphql
type Product
@surrogateKey(
of: "brand category.id" # → Product:brand:{brand}:category.id:{category.id}
)
{
brand: String! @surrogateKey # → Product:brand:{brand}
category: Category!
}
type Category {
id: String! @surrogateKey # → Category:id:{id}
name: String
}

A product catalog rarely has an interface or a union, so this section, and the cases further down that read a field returning one, use a media library instead: books, magazines and movies.

On an interface the directive works as on an object type, and every implementor inherits the keys, so you declare a key once for all of them. An implementor’s own directives add to the inherited keys, and static on an interface gives all implementors a shared key.

An inherited key is named after the interface, with the implementor’s own name beside it: Medium:genre:fiction and Book:genre:fiction for a book. Both are attached wherever the query reaches the implementor: through a field of the interface type (media: [Medium!]), through a field that returns the implementor itself (book: Book), or further down a selection. A purge by either key therefore reaches every cached response that shows the object, whichever field the query used.

Under a field of the interface type, a query that names no implementor in a fragment ({ media { title } }) gets the interface’s keys and their implementor twins. The keys an implementor declares itself, or inherits from another interface or a union, are added where the query names it: { media { ... on Book { title } } }.

The automatic keys are not inherited. An interface with an id: ID! field is keyed Node:id:<id> where it is selected, and every implementor declares the same field and carries User:id:<id> under its own name, so a User reached directly carries no Node:id key.

schema.graphql
interface Medium
@surrogateKey(
of: "author.id genre" # → Medium:author.id:{author.id}:genre:{genre}
)
{
genre: String!
author: Author!
}
# The keys below apply to a Book or Magazine wherever the query reaches it: through a field
# of type Medium, or through a field returning Book or Magazine itself.
type Book implements Medium {
# → Book:author.id:{author.id}:genre:{genre}
genre: String!
author: Author!
}
type Magazine implements Medium {
# → Magazine:author.id:{author.id}:genre:{genre}
genre: String!
author: Author!
}
type Author {
id: String!
name: String
}

Keys on the interface’s fields are inherited too, which gives a purge by genre, by author, or by both, across every implementor and on the interface itself:

schema.graphql
interface Medium
@surrogateKey(
of: "author.id genre" # → Medium:author.id:{author.id}:genre:{genre}
)
{
genre: String! @surrogateKey # → Medium:genre:{genre}
author: Author! @surrogateKey(of: "id") # → Medium:author.id:{author.id}
}
# The keys below apply to a Book or Magazine wherever the query reaches it: through a field
# of type Medium, or through a field returning Book or Magazine itself.
type Book implements Medium {
# → Book:author.id:{author.id}:genre:{genre}
genre: String! # → Book:genre:{genre}
author: Author! # → Book:author.id:{author.id}
}
type Magazine implements Medium {
# → Magazine:author.id:{author.id}:genre:{genre}
genre: String! # → Magazine:genre:{genre}
author: Author! # → Magazine:author.id:{author.id}
}
type Author {
id: String!
name: String
}
Use caseSurrogate key
Media of a specific author and genreMedium:author.id:{author.id}:genre:{genre}
Media of a specific authorMedium:author.id:{author.id}
Media of a specific genreMedium:genre:{genre}
Books of a specific author and genreBook:author.id:{author.id}:genre:{genre}
Books of a specific authorBook:author.id:{author.id}
Books of a specific genreBook:genre:{genre}
Magazines of a specific author and genreMagazine:author.id:{author.id}:genre:{genre}
Magazines of a specific authorMagazine:author.id:{author.id}
Magazines of a specific genreMagazine:genre:{genre}

Through a field returning the implementor the field keys apply as well: Medium.author @surrogateKey(of: "id") keys book { author } with Medium:author.id:{author.id} and Book:author.id:{author.id}, exactly as it keys media { author }.

A type that also defines its own @surrogateKey gets both the inherited and its local keys. The same holds for a union member.

On a union the directive is a shared contract across the members, with of checked per member. Every member that has the fields the key names gets the key, each resolving to a scalar, an enum, or a list of either; a member that lacks them gets none. The keys are named after the union, with the member’s own name beside them, and they are attached wherever the query reaches the member. Under a field of the union type that is the members the query names in a fragment: { contents { __typename } } carries none of them. A field that returns a member type itself (book: Book) carries the union’s keys as well as the member’s own.

schema.graphql
union Content
@surrogateKey(of: "author.id genre") # → Content:author.id:{author.id}:genre:{genre}
=
| Book
| Magazine
| Movie
# The keys below apply to a member reached through a field of type Content and
# named in a fragment of the query, or through a field returning the member itself.
type Book {
# → Book:author.id:{author.id}:genre:{genre}
genre: String!
author: Author!
}
type Magazine {
# → Magazine:author.id:{author.id}:genre:{genre}
genre: String!
author: Author!
}
# No key is generated for this type
# because the path `author.id` does not exist on `Movie`
type Movie {
genre: String!
director: Author!
}
type Author {
id: String!
name: String
}

A union has no fields of its own, so each further dimension, such as the director, is another directive with its own selection:

schema.graphql
union Content
@surrogateKey(of: "author.id genre") # → Content:author.id:{author.id}:genre:{genre}
@surrogateKey(of: "director.id genre") # → Content:director.id:{director.id}:genre:{genre}
=
| Book
| Magazine
| Movie
# The keys below apply to a member reached through a field of type Content and
# named in a fragment of the query, or through a field returning the member itself.
type Book {
# → Book:author.id:{author.id}:genre:{genre}
genre: String!
author: Author!
}
type Magazine {
# → Magazine:author.id:{author.id}:genre:{genre}
genre: String!
author: Author!
}
type Movie {
# → Movie:director.id:{director.id}:genre:{genre}
genre: String!
director: Author!
}
type Author {
id: String!
name: String
}

One directive per selection gives a purge by author, by director, by genre, or a combination, across every member and on the union itself:

schema.graphql
union Content
@surrogateKey(of: "author.id genre") # → Content:author.id:{author.id}:genre:{genre}
@surrogateKey(of: "author.id") # → Content:author.id:{author.id}
@surrogateKey(of: "director.id genre") # → Content:director.id:{director.id}:genre:{genre}
@surrogateKey(of: "director.id") # → Content:director.id:{director.id}
@surrogateKey(of: "genre") # → Content:genre:{genre}
=
| Book
| Magazine
| Movie
# The keys below apply to a member reached through a field of type Content and
# named in a fragment of the query, or through a field returning the member itself.
type Book {
# → Book:author.id:{author.id}:genre:{genre}
genre: String! # → Book:genre:{genre}
author: Author! # → Book:author.id:{author.id}
}
type Magazine {
# → Magazine:author.id:{author.id}:genre:{genre}
genre: String! # → Magazine:genre:{genre}
author: Author! # → Magazine:author.id:{author.id}
}
type Movie {
# → Movie:director.id:{director.id}:genre:{genre}
genre: String! # → Movie:genre:{genre}
director: Author! # → Movie:director.id:{director.id}
}
type Author {
id: String!
name: String
}
Use caseSurrogate key
Contents of a specific author and genreContent:author.id:{author.id}:genre:{genre}
Contents of a specific authorContent:author.id:{author.id}
Contents of a specific director and genreContent:director.id:{director.id}:genre:{genre}
Contents of a specific directorContent:director.id:{director.id}
Contents of a specific genreContent:genre:{genre}
Books of a specific author and genreBook:author.id:{author.id}:genre:{genre}
Books of a specific authorBook:author.id:{author.id}
Books of a specific genreBook:genre:{genre}
Magazines of a specific author and genreMagazine:author.id:{author.id}:genre:{genre}
Magazines of a specific authorMagazine:author.id:{author.id}
Magazines of a specific genreMagazine:genre:{genre}
Movies of a specific director and genreMovie:director.id:{director.id}:genre:{genre}
Movies of a specific genreMovie:genre:{genre}
Movies of a specific directorMovie:director.id:{director.id}

If of resolves for no member at all, the deployment build reports one schema error naming the union.

A key on a field that returns an object, an interface or a union names what that field returns, from the parent’s side: @surrogateKey(of: "id") on Category.featured puts Category:featured.id:p01 on every response that selected a category’s featured product p01. The field name is part of the key, so keys from two fields of the same type never reach each other’s responses. Such a field needs of, static or both; static attaches a fixed key that is included only when the field is queried.

schema.graphql
type Category {
id: ID!
name: String
featured: Product @surrogateKey(of: "id") # → Category:featured.id:{featured.id}
}
type Product {
id: ID!
name: String
}

A composite key on such a field combines several subfields of the related type:

schema.graphql
type Library {
books: [Book!] @surrogateKey(of: "author.id genre") # → Library:books.author.id:{books.author.id}:books.genre:{books.genre}
}
type Book {
genre: String!
author: Author!
}
type Author {
id: ID!
name: String
}

On a field that returns an interface or a union, of is checked per member or implementor, as on a union. Where members expose different leaf fields, repeat the directive with a selection for each:

schema.graphql
type Library {
media: [Medium!] @surrogateKey(of: "author.id genre") # → Library:media.author.id:{media.author.id}:media.genre:{media.genre}
contents: [Content!]
@surrogateKey(of: "author.id genre") # → Library:contents.author.id:{contents.author.id}:contents.genre:{contents.genre}
@surrogateKey(of: "director.id genre")
# → Library:contents.director.id:{contents.director.id}:contents.genre:{contents.genre}
}
union Content = Book | Magazine | Movie
interface Medium {
genre: String!
author: Author!
}
type Book implements Medium {
genre: String!
author: Author!
}
type Magazine implements Medium {
genre: String!
author: Author!
}
type Movie {
genre: String! @surrogateKey # → Movie:genre:{genre}
director: Author!
}
type Author {
id: String!
name: String
}
Use caseSurrogate key
Library media
of a specific author and genre
Library:media.author.id:{author.id}:media.genre:{genre}
Library contents
of a specific author and genre
Library:contents.author.id:{author.id}:contents.genre:{genre}
Library contents
of a specific director and genre
Library:contents.director.id:{director.id}:contents.genre:{genre}
Movies of a specific genreMovie:genre:{genre}

A key on a field belongs to the enclosing type only. It gives no key to the field’s return types (Book, Magazine, Movie). A key a member declares itself, such as Movie.genre, applies to that member alongside the field’s keys, and to no other member.

The static argument defines a fixed key. Use it to give a group of responses a shared handle you can purge by hand, without introducing a new type. Whitespace separates keys, so you can declare several keys on one directive or repeat the directive:

schema.graphql
type Product @surrogateKey(static: "catalogue") {
# → catalogue
name: String!
}
type Category @surrogateKey(static: "catalogue") {
# → catalogue
name: String!
}
# Define 3 static keys on the same type:
type Promotion
@surrogateKey(static: "catalogue") # → catalogue
@surrogateKey(static: "promo banner")
{
# → promo, banner (whitespace separates keys)
title: String!
}

So static: "promo banner" declares two separate keys, promo and banner; there is no way to put a space inside a single static key. Each key is then folded into printable ASCII. Often a union or an interface is the more natural handle.

A static key on a leaf type attaches to any response that selected a field returning that type, wherever the field sits.

scalar Money @surrogateKey(static: "ledger")
type Invoice {
id: ID!
total: Money!
}

A query for invoice { total } carries ledger; a query for invoice { id } does not.

On an OBJECT, INTERFACE or UNION, by contrast, static and of on the same directive contribute independently. With the directive applied twice as above, a selection of Profile.points carries loyalty and Profile:points:<value>.

A static key on the union itself is added whenever the union is selected, and whenever a field returning one of its members is. A static key on one of its members is added whenever the query names that member in a fragment, or selects a field returning the member.

union Content @surrogateKey(static: "all-content") = NewsArticle | BlogArticle
type BlogArticle @surrogateKey(static: "blogroll") { title: String! }

A query naming both members carries all-content and blogroll, whether or not a blog article came back. A query naming only ... on NewsArticle carries all-content and not blogroll, whatever the response holds. The same rule applies to an interface and its implementors.

How lists, null values and aliases turn into key values, and which directives the deployment build refuses, is on how keys are built.

The two automatic rules can pick a field that merely shares a key name or type, and produce a key that identifies nothing: a key field holding rendered text, say. Such a key still takes room in the key budget and no purge will ever name it. ignore: true switches both rules off where the directive sits:

schema.graphql
# No automatic key for any field of this type
type RenderedSnippet @surrogateKey(ignore: true) {
id: ID!
key: String
}
# Product keeps Product:id:<id>, but not Product:key:<key>
type Product {
id: ID!
key: String @surrogateKey(ignore: true)
}

An exclusion only switches off the automatic rules; it never removes a key you wrote. Where it applies and how it meets the configuration’s ignored_types and ignored_fields is on the directive reference.

If your schema already marks keys with another directive, such as Apollo Federation’s @key, GraphPilot can read that one instead:

gpilot.toml
[[surrogate_key.directives]]
directive_name = "key" # the directive as it appears in your schema, without the @
of_arg = "fields" # its argument that lists the key fields

An entry replaces the built-in @surrogateKey rather than adding to it. Every key of an entry, including a marker directive with no arguments, is in the configuration reference. The exact definition and arguments of @surrogateKey are on the directive reference.