GraphQL Schema Basics
If you learn best by doing, check out the intro tutorial to GraphQL with TypeScript & Apollo Server.
Your GraphQL server uses a schema to describe the shape of your available data. This schema defines a hierarchy of types with fields that are populated from your back-end data stores. The schema also specifies exactly which queries and mutations are available for clients to execute.
This article describes the fundamental building blocks of a schema and how to create one for your GraphQL server.
The schema definition language
The GraphQL specification defines a human-readable schema definition language (or SDL) that you use to define your schema and store it as a string.
Here's a short example schema that defines two object types: Book
and Author
:
1type Book {
2 title: String
3 author: Author
4}
5
6type Author {
7 name: String
8 books: [Book]
9}
A schema defines a collection of types and the relationships between those types. In the example schema above, a Book
can have an associated author
, and an Author
can have a list of books
.
Because these relationships are defined in a unified schema, client developers can see exactly what data is available and then request a specific subset of that data with a single optimized query.
Note that the schema is not responsible for defining where data comes from or how it's stored. It is entirely implementation-agnostic.
Field definitions
Most of the schema types you define have one or more fields:
1# This Book type has two fields: title and author
2type Book {
3 title: String # returns a String
4 author: Author # returns an Author
5}
Each field returns data of the type specified. A field's return type can be a scalar, object, enum, union, or interface (all described below).
List fields
A field can return a list containing items of a particular type. You indicate list fields with square brackets []
, like so:
1type Author {
2 name: String
3 books: [Book] # A list of Books
4}
List fields can be nested by using multiple pairs of square brackets
[[]]
.
Field nullability
By default, it's valid for any field in your schema to return null
instead of its specified type. You can require that a particular field doesn't return null
with an exclamation mark !
, like so:
1type Author {
2 name: String! # Can't return null
3 books: [Book]
4}
These fields are non-nullable. If your server attempts to return null
for a non-nullable field, an error is thrown.
Nullability and lists
With a list field, an exclamation mark !
can appear in any combination of two locations:
1type Author {
2 books: [Book!]! # This list can't be null AND its list *items* can't be null
3}
If
!
appears inside the square brackets, the returned list can't include items that arenull
.If
!
appears outside the square brackets, the list itself can't benull
.In any case, it's valid for a list field to return an empty list.
Based on the above principles, the below return types can potentially return these sample values:
Return Type | Example Allowed Return Values |
---|---|
[Book] | [] , null , [null] , and [{title: "City of Glass"}] |
[Book!] | [] , null , and [{title: "City of Glass"}] |
[Book]! | [] , [null] , and [{title: "City of Glass"}] |
[Book!]! | [] and [{title: "City of Glass"}] |
Supported types
Every type definition in a GraphQL schema belongs to one of the following categories:
This includes the three special root operation types:
Query
,Mutation
, andSubscription
.
Each of these is described below.
Scalar types
Scalar types are similar to primitive types in your favorite programming language. They always resolve to concrete data.
GraphQL's default scalar types are:
Int
: A signed 32‐bit integerFloat
: A signed double-precision floating-point valueString
: A UTF‐8 character sequenceBoolean
:true
orfalse
ID
(serialized as aString
): A unique identifier that's often used to refetch an object or as the key for a cache. Although it's serialized as aString
, anID
is not intended to be human‐readable.
These primitive types cover the majority of use cases. For more specific use cases, you can create custom scalar types.
Object types
Most of the types you define in a GraphQL schema are object types. An object type contains a collection of fields, each of which has its own type.
Two object types can include each other as fields, as is the case in our example schema from earlier:
1type Book {
2 title: String
3 author: Author
4}
5
6type Author {
7 name: String
8 books: [Book]
9}
The __typename
field
Every object type in your schema automatically has a field named __typename
(you don't need to define it). The __typename
field returns the object type's name as a String
(e.g., Book
or Author
).
GraphQL clients use an object's __typename
for many purposes, such as to determine which type was returned by a field that can return multiple types (i.e., a union or interface). Apollo Client relies on __typename
when caching results, so it automatically includes __typename
in every object of every query.
Because __typename
is always present, this is a valid query for any GraphQL server:
1query UniversalQuery {
2 __typename
3}
The Query
type
The Query
type is a special object type that defines all of the top-level entry points for queries that clients execute against your server.
Each field of the Query
type defines the name and return type of a different entry point. The Query
type for our example schema might resemble the following:
1type Query {
2 books: [Book]
3 authors: [Author]
4}
This Query
type defines two fields: books
and authors
. Each field returns a list of the corresponding type.
With a REST-based API, books and authors would probably be returned by different endpoints (e.g., /api/books
and /api/authors
). The flexibility of GraphQL enables clients to query both resources with a single request.
Structuring a query
When your clients build queries to execute against your graph, those queries match the shape of the object types you define in your schema.
Based on our example schema so far, a client could execute the following query, which requests both a list of all book titles and a list of all author names:
1query GetBooksAndAuthors {
2 books {
3 title
4 }
5
6 authors {
7 name
8 }
9}
Our server would then respond to the query with results that match the query's structure, like so:
1{
2 "data": {
3 "books": [
4 {
5 "title": "City of Glass"
6 },
7 ...
8 ],
9 "authors": [
10 {
11 "name": "Paul Auster"
12 },
13 ...
14 ]
15 }
16}
Although it might be useful in some cases to fetch these two separate lists, a client would probably prefer to fetch a single list of books, where each book's author is included in the result.
Because our schema's Book
type has an author
field of type Author
, a client could instead structure their query like so:
1query GetBooks {
2 books {
3 title
4 author {
5 name
6 }
7 }
8}
And once again, our server would respond with results that match the query's structure:
1{
2 "data": {
3 "books": [
4 {
5 "title": "City of Glass",
6 "author": {
7 "name": "Paul Auster"
8 }
9 },
10 ...
11 ]
12 }
13}
The Mutation
type
The Mutation
type is similar in structure and purpose to the Query
type. Whereas the Query
type defines entry points for read operations, the Mutation
type defines entry points for write operations.
Each field of the Mutation
type defines the signature and return type of a different entry point. The Mutation
type for our example schema might resemble the following:
1type Mutation {
2 addBook(title: String, author: String): Book
3}
This Mutation
type defines a single available mutation, addBook
. The mutation accepts two arguments (title
and author
) and returns a newly created Book
object. As you'd expect, this Book
object conforms to the structure that we defined in our schema.
Structuring a mutation
Like queries, mutations match the structure of your schema's type definitions. The following mutation creates a new Book
and requests certain fields of the created object as a return value:
1mutation CreateBook {
2 addBook(title: "Fox in Socks", author: "Dr. Seuss") {
3 title
4 author {
5 name
6 }
7 }
8}
As with queries, our server would respond to this mutation with a result that matches the mutation's structure, like so:
1{
2 "data": {
3 "addBook": {
4 "title": "Fox in Socks",
5 "author": {
6 "name": "Dr. Seuss"
7 }
8 }
9 }
10}
A single mutation operation can include multiple top-level fields of the Mutation
type. This usually means that the operation will execute multiple back-end writes (at least one for each field). To prevent race conditions, top-level Mutation
fields are resolved serially in the order they're listed (all other fields can be resolved in parallel).
Learn more about designing mutations
The Subscription
type
See Subscriptions.
Input types
Input types are special object types that allow you to provide hierarchical data as arguments to fields (as opposed to providing only flat scalar arguments).
An input type's definition is similar to an object type's, but it uses the input
keyword:
1input BlogPostContent {
2 title: String
3 body: String
4}
Each field of an input type can be only a scalar, an enum, or another input type:
1input BlogPostContent {
2 title: String
3 body: String
4 media: [MediaDetails!]
5}
6
7input MediaDetails {
8 format: MediaFormat!
9 url: String!
10}
11
12enum MediaFormat {
13 IMAGE
14 VIDEO
15}
After you define an input type, any number of different object fields can accept that type as an argument:
1type Mutation {
2 createBlogPost(content: BlogPostContent!): Post
3 updateBlogPost(id: ID!, content: BlogPostContent!): Post
4}
Input types can sometimes be useful when multiple operations require the exact same set of information, but you should reuse them sparingly. Operations might eventually diverge in their sets of required arguments.
Take care if using the same input type for fields of both
Query
andMutation
. In many cases, arguments that are required for a mutation are optional for a corresponding query. You might want to create separate input types for each operation type.
Enum types
An enum is similar to a scalar type, but its legal values are defined in the schema. Here's an example definition:
1enum AllowedColor {
2 RED
3 GREEN
4 BLUE
5}
Enums are most useful in situations where the user must pick from a prescribed list of options. As an additional benefit, enum values autocomplete in tools like the Apollo Studio Explorer.
An enum can appear anywhere a scalar is valid (including as a field argument), because they serialize as strings:
1type Query {
2 favoriteColor: AllowedColor # enum return value
3 avatar(borderColor: AllowedColor): String # enum argument
4}
A query might then look like this:
1query GetAvatar {
2 avatar(borderColor: RED)
3}
Internal values (advanced)
Sometimes, a backend forces a different value for an enum internally than in the public API. You can set each enum value's corresponding internal value in the resolver map you provide to Apollo Server.
This feature usually isn't required unless another library in your application expects enum values in a different form.
The following example uses color hex codes for each AllowedColor
's internal value:
1const resolvers = {
2 AllowedColor: {
3 RED: '#f00',
4 GREEN: '#0f0',
5 BLUE: '#00f',
6 },
7 // ...other resolver definitions...
8};
These internal values don't change the public API at all. Apollo Server resolvers accept these values instead of the schema values, as shown:
1const resolvers = {
2 AllowedColor: {
3 RED: '#f00',
4 GREEN: '#0f0',
5 BLUE: '#00f',
6 },
7 Query: {
8 favoriteColor: () => '#f00',
9 avatar: (parent, args) => {
10 // args.borderColor is '#f00', '#0f0', or '#00f'
11 },
12 },
13};
Union and interface types
Growing with a schema
As your organization grows and evolves, your graph grows and evolves with it. New products and features introduce new schema types and fields. To track these changes over time, you should maintain your schema's definition in version control.
Most additive changes to a schema are safe and backward compatible. However, changes that remove or alter existing behavior might be breaking changes for one or more of your existing clients. All of the following schema changes are potentially breaking changes:
Removing a type or field
Renaming a type or field
Adding nullability to a field
Removing a field's arguments
A graph management tool such as Apollo Studio helps you understand whether a potential schema change will impact any of your active clients. Studio also provides field-level performance metrics, schema history tracking, and advanced security via operation safelisting.
Descriptions (docstrings)
GraphQL's schema definition language (SDL) supports Markdown-enabled documentation strings, called descriptions. These help consumers of your graph discover fields and learn how to use them.
The following snippet shows how to use both single-line string literals and multi-line blocks:
1"Description for the type"
2type MyObjectType {
3 """
4 Description for field
5 Supports **multi-line** description for your [API](http://example.com)!
6 """
7 myField: String!
8
9 otherField(
10 "Description for argument"
11 arg: Int
12 )
13}
A well documented schema helps provide an enhanced development experience, because GraphQL development tools (such as the Apollo Studio Explorer) auto-complete field names along with descriptions when they're provided. Furthermore, Apollo Studio displays descriptions alongside field usage and performance details when using its metrics reporting and client awareness features.
Naming conventions
The GraphQL specification is flexible and doesn't impose specific naming guidelines. However, it's helpful to establish a set of conventions to ensure consistency across your organization. We recommend the following:
Field names should use
camelCase
. Many GraphQL clients are written in JavaScript, Java, Kotlin, or Swift, all of which recommendcamelCase
for variable names.Type names should use
PascalCase
. This matches how classes are defined in the languages mentioned above.Enum names should use
PascalCase
.Enum values should use
ALL_CAPS
, because they are similar to constants.
These conventions help ensure that most clients don't need to define extra logic to transform the results returned by your server.
Query-driven schema design
A GraphQL schema is most powerful when it's designed for the needs of the clients that will execute operations against it. Although you can structure your types so they match the structure of your back-end data stores, you don't have to! A single object type's fields can be populated with data from any number of different sources. Design your schema based on how data is used, not based on how it's stored.
If your data store includes a field or relationship that your clients don't need yet, omit it from your schema. It's easier and safer to add a new field to a schema than it is to remove an existing field that some of your clients are using.
Example of a query-driven schema
Let's say we're creating a web app that lists upcoming events in our area. We want the app to show the name, date, and location of each event, along with the weather forecast for it.
In this case, we want our web app to be able to execute a query with a structure similar to the following:
1query EventList {
2 upcomingEvents {
3 name
4 date
5 location {
6 name
7 weather {
8 temperature
9 description
10 }
11 }
12 }
13}
Because we know this is the structure of data that would be helpful for our client, that can inform the structure of our schema:
1type Query {
2 upcomingEvents: [Event!]!
3}
4
5type Event {
6 name: String!
7 date: String!
8 location: Location
9}
10
11type Location {
12 name: String!
13 weather: WeatherInfo
14}
15
16type WeatherInfo {
17 temperature: Float
18 description: String
19}
As mentioned, each of these types can be populated with data from a different data source (or multiple data sources). For example, the Event
type's name
and date
might be populated with data from our back-end database, whereas the WeatherInfo
type might be populated with data from a third-party weather API.
Designing mutations
In GraphQL, it's recommended for every mutation's response to include the data that the mutation modified. This enables clients to obtain the latest persisted data without needing to send a followup query.
A schema that supports updating the email
of a User
would include the following:
1type Mutation {
2 # This mutation takes id and email parameters and responds with a User
3 updateUserEmail(id: ID!, email: String!): User
4}
5
6type User {
7 id: ID!
8 name: String!
9 email: String!
10}
A client could then execute a mutation against the schema with the following structure:
1mutation updateMyUser {
2 updateUserEmail(id: 1, email: "jane@example.com") {
3 id
4 name
5 email
6 }
7}
After the GraphQL server executes the mutation and stores the new email address for the user, it responds to the client with the following:
1{
2 "data": {
3 "updateUserEmail": {
4 "id": "1",
5 "name": "Jane Doe",
6 "email": "jane@example.com"
7 }
8 }
9}
Although it isn't mandatory for a mutation's response to include the modified object, doing so greatly improves the efficiency of client code. And as with queries, determining which mutations would be useful for your clients helps inform the structure of your schema.
Structuring mutation responses
A single mutation can modify multiple types, or multiple instances of the same type. For example, a mutation that enables a user to "like" a blog post might increment the likes
count for a Post
and update the likedPosts
list for the User
. This makes it less obvious what the structure of the mutation's response should look like.
Additionally, mutations are much more likely than queries to cause errors, because they modify data. A mutation might even result in a partial error, in which it successfully modifies one piece of data and fails to modify another. Regardless of the type of error, it's important that the error is communicated back to the client in a consistent way.
To help resolve both of these concerns, we recommend defining a MutationResponse
interface in your schema, along with a collection of object types that implement that interface (one for each of your mutations).
Here's what a MutationResponse
interface might look like:
1interface MutationResponse {
2 code: String!
3 success: Boolean!
4 message: String!
5}
And here's what an object that implements MutationResponse
might look like:
1type UpdateUserEmailMutationResponse implements MutationResponse {
2 code: String!
3 success: Boolean!
4 message: String!
5 user: User
6}
Our updateUserEmail
mutation would specify UpdateUserEmailMutationResponse
as its return type (instead of User
), and the structure of its response would be the following:
1{
2 "data": {
3 "updateUserEmail": {
4 "code": "200",
5 "success": true,
6 "message": "User email was successfully updated",
7 "user": {
8 "id": "1",
9 "name": "Jane Doe",
10 "email": "jane@example.com"
11 }
12 }
13 }
14}
Let’s break this down field by field:
code
is a string that represents the status of the data transfer. Think of it like an HTTP status code.success
is a boolean that indicates whether the mutation was successful. This allows a coarse check by the client to know if there were failures.message
is a human-readable string that describes the result of the mutation. It is intended to be used in the UI of the product.user
is added by the implementing typeUpdateUserEmailMutationResponse
to return the newly updated user to the client.
If a mutation modifies multiple types (like our earlier example of "liking" a blog post), its implementing type can include a separate field for each type that's modified:
1type LikePostMutationResponse implements MutationResponse {
2 code: String!
3 success: Boolean!
4 message: String!
5 post: Post
6 user: User
7}
Because our hypothetical likePost
mutation modifies fields on both a Post
and a User
, its response object includes fields for both of those types. A response has the following structure:
1{
2 "data": {
3 "likePost": {
4 "code": "200",
5 "success": true,
6 "message": "Thanks!",
7 "post": {
8 "id": "123",
9 "likes": 5040
10 },
11 "user": {
12 "likedPosts": ["123"]
13 }
14 }
15 }
16}
Following this pattern provides a client with helpful, detailed information about the result of each requested operation. Equipped with this information, developers can better react to operation failures in their client code.