GraphQL schemas are like contracts between your API and its clients, and evolving that contract without breaking things is a surprisingly subtle art. The most counterintuitive truth is that you can add almost anything to a GraphQL schema without breaking existing clients, but removing or changing things is where the danger lies.

Let’s see this in action. Imagine we have a simple schema for a blog:

type Post {
  id: ID!
  title: String!
  body: String
  author: User
}

type User {
  id: ID!
  name: String!
  email: String
}

type Query {
  post(id: ID!): Post
  posts: [Post!]!
}

A client might query for a post like this:

query GetPostDetails($postId: ID!) {
  post(id: $postId) {
    id
    title
    author {
      name
    }
  }
}

Now, let’s say we want to add a publishedAt timestamp to our Post type. We can simply add it to the schema:

type Post {
  id: ID!
  title: String!
  body: String
  author: User
  publishedAt: String # Added field
}

type User {
  id: ID!
  name: String!
  email: String
}

type Query {
  post(id: ID!): Post
  posts: [Post!]!
}

The client’s query GetPostDetails still works perfectly because it only asks for id, title, and author.name. It simply ignores the new publishedAt field. This is the magic of GraphQL – clients only fetch what they explicitly ask for.

What if we want to add a new query to fetch all posts by a specific author?

type Post {
  id: ID!
  title: String!
  body: String
  author: User
  publishedAt: String
}

type User {
  id: ID!
  name: String!
  email: String
}

type Query {
  post(id: ID!): Post
  posts: [Post!]!
  postsByAuthor(authorId: ID!): [Post!]! # New query
}

Again, existing clients are unaffected. They don’t know postsByAuthor exists, so they won’t try to call it.

The real danger comes when you remove or change fields. If we were to remove the title field from Post:

type Post {
  id: ID!
  # title: String! # Removed field
  body: String
  author: User
  publishedAt: String
}
// ... rest of schema

Our GetPostDetails query would now fail because the title field it requests no longer exists. The GraphQL server would return an error like:

{
  "errors": [
    {
      "message": "Cannot query field \"title\" on type \"Post\".",
      "locations": [
        {
          "line": 4,
          "column": 5
        }
      ]
    }
  ],
  "data": {
    "post": null
  }
}

Similarly, changing a field’s type is a breaking change. If we changed title from String! to Int:

type Post {
  id: ID!
  title: Int # Changed type
  body: String
  author: User
  publishedAt: String
}
// ... rest of schema

The client expecting a String would now receive an Int and likely crash or misbehave.

The core principle for evolving your schema without breaking clients is to favor additions over deletions or modifications. When you need to change something, the safest approach is often to deprecate the old field and introduce a new one. For instance, if publishedAt was initially a String and you want to make it a proper DateTime scalar:

  1. Add the new field:
    type Post {
      id: ID!
      title: String!
      body: String
      author: User
      publishedAt: String # Old field
      publishedAtDateTime: DateTime # New field, assuming DateTime is a custom scalar
    }
    // ...
    
  2. Deprecate the old field:
    type Post {
      id: ID!
      title: String!
      body: String
      author: User
      publishedAt: String @deprecated(reason: "Use publishedAtDateTime instead.") # Deprecated
      publishedAtDateTime: DateTime
    }
    // ...
    
    The @deprecated directive signals to clients and tooling that this field is on its way out. GraphQL tooling can then warn developers using the deprecated field.
  3. Migrate clients: Over time, clients can update their queries to use publishedAtDateTime.
  4. Remove the old field: Once you’re confident all clients have migrated, you can safely remove publishedAt.

The GraphQL specification itself doesn’t mandate how clients or servers handle deprecation, but the @deprecated directive is the standard way to communicate intent. Tools like Apollo provide runtime support to automatically resolve deprecated fields to their replacements if you implement specific logic. For example, your publishedAt resolver could check if publishedAtDateTime is requested and, if not, try to parse the publishedAt string and return it. This can buy you more time for clients to migrate.

The concept of "schema stitching" or "federation" allows you to combine multiple GraphQL schemas into a single, unified API. This is a powerful way to manage large, distributed GraphQL deployments, where different teams might own different parts of the schema. When evolving a federated schema, you apply the same principles of additive changes within each sub-schema, ensuring that the overall gateway schema remains stable for clients.

When you’ve successfully navigated schema evolution, the next challenge is often optimizing the performance of your GraphQL queries, especially when dealing with complex data fetching and N+1 problem patterns.

Want structured learning?

Take the full Graphql-tools course →