Schema Change Management
GraphQL schemas evolve continuously rather than through versioned releases. This approach allows you to add capabilities without breaking existing clients while maintaining a single schema that serves all consumers.
This guide shows you how to evolve your schema safely using additive changes, deprecation, and migration strategies that minimize disruption to clients.
Understand schema evolution
GraphQL favors evolution over versioning. Instead of releasing v1, v2, v3 of your entire API, you make incremental backward-compatible changes to a single schema that all clients use.
This works because GraphQL clients request only the fields they need. When you add new fields or types, existing queries ignore them and continue working unchanged. Clients adopt new capabilities at their own pace without forced migrations.
Evolution doesn’t forbid versioning entirely. You could create /graphql/v2 for major overhauls. However, this sacrifices GraphQL’s benefits and forces you to maintain multiple schemas simultaneously. Most organizations stick with continuous evolution and use careful planning to roll out changes.
Make additive changes
The safest schema changes add new capabilities without modifying existing ones. These changes don’t break any current queries.
Safe additive changes include:
- Adding new fields to existing types
- Adding new types
- Adding new queries or mutations
- Adding optional arguments to fields
- Making required fields optional
# Before
type User {
id: ID!
email: String!
}
# After, with a new field added safely
type User {
id: ID!
email: String!
createdAt: DateTime!
}This example adds a createdAt field to the User type. Existing queries that request id and email continue working exactly as before. New clients can request createdAt whenever they’re ready to use it.
When adding new fields, make them nullable or provide sensible defaults unless you have certainty they’ll always have values. Nullable fields let you return null for older data that lacks the new information, maintaining backward compatibility.
Handle optional arguments carefully
Adding optional arguments to fields is generally safe, but requires default behavior that matches how the field worked before the argument existed.
type Query {
products(
first: Int = 20
sortBy: ProductSort = POPULARITY
): [Product!]!
}This example adds a sortBy argument to an existing products query. The default value, POPULARITY ensures the query behaves identically for clients that don’t provide the argument. New clients can specify different sorting when needed.
To implement this safely, your resolver must handle the argument being absent and provide behavior that matches existing client expectations.
Identify breaking changes
A breaking change is any change that breaks the server/client contract. This happens when previously valid operations become invalid or when the shape of the returned data changes.
Common breaking changes include:
- Removing fields or types
- Renaming fields or types
- Changing field types, such as
StringtoInt - Removing or renaming enum values
- Making optional arguments required
- Changing argument types
- Making a non-null field nullable
Removing a field:
# Before
type User {
id: ID!
name: String!
email: String!
}
# After — queries requesting `email` will fail
type User {
id: ID!
name: String!
}Changing a field type:
# Before
type Product {
id: ID!
price: Float!
}
# After — clients expecting Float receive Money instead
type Product {
id: ID!
price: Money!
}Making a non-null field nullable:
# Before
type Order {
id: ID!
discount: Float
}
# After — queries might receive errors if discount is null
type Order {
id: ID!
discount: Float!
}These examples show schema changes that break existing queries. Clients requesting the removed email field receive validation errors. Clients expecting a Float for price get a Money object instead. Queries relying on non-null discounts may crash.
Avoid these changes when possible. When unavoidable, use the deprecation process to give clients time to migrate.
Deprecate fields before removal
The @deprecated directive marks fields and enum values as obsolete while keeping them functional. This gives clients advance warning to update their queries before you remove the deprecated element.
type User {
id: ID!
name: String! @deprecated(reason: "Use firstName and lastName instead")
firstName: String!
lastName: String!
}This example deprecates the name field while providing firstName and lastName as replacements. The reason parameter explains what clients should do instead.
Clients see deprecation warnings in GraphQL tools like GraphiQL, for example. The field still works, allowing gradual migration rather than immediate breakage.
To deprecate effectively, provide a clear reason explaining what clients should use instead and keep the deprecated field fully functional throughout the migration period. Track usage metrics so you know when removal is safe, and communicate the deprecation timeline to client teams.
Maintain deprecated fields
During the deprecation period, keep the old field working. Implement it by delegating to the new structure so clients get consistent data regardless of which field they query.
export const resolvers = {
User: {
name: (user) => {
// Maintain backward compatibility by combining new fields
return `${user.firstName} ${user.lastName}`;
},
firstName: (user) => user.firstName,
lastName: (user) => user.lastName
}
};This example keeps the deprecated name field functional by constructing it from firstName and lastName. Clients using the old field receive correct data while they migrate to the new structure.
When implementing deprecated fields, log warnings when they’re accessed. This telemetry helps you track which clients still depend on deprecated elements and when usage drops low enough for safe removal.
Follow the deprecation lifecycle
Use a predictable process for introducing breaking changes: add the new element, deprecate the old one, migrate clients, then remove the deprecated element.
Add new capabilities first
Before deprecating anything, add the replacement field, type, or argument. Ensure it provides all functionality clients need from the deprecated element.
Announce deprecations
Communicate deprecations clearly and well in advance. Public APIs should announce breaking changes months ahead. Some organizations announce GraphQL changes three months before implementation and make changes only at quarter boundaries.
Internal APIs can use shorter timelines but still need clear communication. Send notifications to client teams, update your documentation, and publish changelogs explaining what’s deprecated and what to use instead.
Track migration progress
Monitor which clients still use deprecated fields. Implement tracking that logs when deprecated elements are accessed, including which client made the request.
export function wrapDeprecatedResolver(resolver, fieldName, reason) {
return (parent, args, context, info) => {
// Log deprecated field access
context.metrics.recordDeprecatedFieldUsage({
field: fieldName,
client: context.clientId,
timestamp: Date.now()
});
// Return the actual result
return resolver(parent, args, context, info);
};
}This example wraps resolvers for deprecated fields to track usage. It records which client accessed the deprecated field so you can identify who needs to migrate.
To track effectively, store deprecated field usage with client identifiers in your metrics system. Query this data regularly to identify clients that haven’t migrated and contact those teams directly when deprecation deadlines approach.
Remove after migration completes
Remove deprecated elements only when usage drops to acceptable levels or the deadline passes. For critical systems, wait until usage reaches zero. For less critical fields, you might remove after usage drops below a threshold appropriate for your context.
Before removing, send final warnings to any remaining clients with a specific deadline and offer migration assistance if needed. After removal, monitor for errors that might indicate missed clients.
Handle dangerous changes
Some changes appear safe but can cause subtle issues. These “dangerous” changes don’t break the schema structurally and robust applications can handle most of them but caution must be exercised.
Adding enum values
Adding values to an enum is technically additive, but clients might not handle unknown values gracefully.
# Before
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
}
# After — new value might surprise clients
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
}This example adds CANCELLED to an existing enum. Clients with switch statements or exhaustive pattern matching might not handle the new value, leading to runtime errors or unexpected behavior.
When adding enum values, document them clearly and consider whether clients need updates to handle the new case. Some teams add new enum values behind feature flags initially to control rollout.
Adding interface implementations
When you add a new type implementing an existing interface, queries returning that interface might receive the new type unexpectedly.
interface Node {
id: ID!
}
type User implements Node {
id: ID!
name: String!
}
type Organization implements Node { # New type
id: ID!
name: String!
members: [User!]!
}This example adds Organization as a new implementation of Node. Queries selecting Node might now receive Organization objects. Clients using type checks or fragment spreads need to handle the new type.
When adding interface implementations, communicate to clients that new types might appear in responses. Encourage clients to use proper type checking with __typename rather than assuming specific types.
Coordinate breaking changes across teams
When breaking changes affect multiple teams, coordinate the migration carefully to minimize disruption.