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.
Establish deprecation timelines
Set clear windows for each phase of the deprecation process:
- Announcement: Notify all consuming teams of the upcoming change
- Deprecation period: Keep old and new elements available simultaneously
- Migration deadline: Date by which clients must complete migration
- Removal date: When the deprecated element disappears from the schema
Longer timelines work better for public APIs or mobile apps where users control update timing. Shorter timelines suffice for internal services where you coordinate deployments directly.
Provide migration guidance
Give clients specific instructions for migrating from deprecated elements to their replacements. Document what changes they need in their queries, what new data structures to expect, and any behavioral differences.
When providing migration support, offer office hours or dedicated channels where teams can ask questions, and review client queries proactively to identify teams affected by the change.
Plan schema migrations
Large schema changes sometimes require coordinated data and code migrations. Plan these carefully to avoid downtime or data inconsistencies.
Coordinate with data migrations
When schema changes require underlying data modifications, complete data migration before publishing the schema change.
For example, if you’re splitting a name field into firstName and lastName, migrate existing data first:
- Add
firstNameandlastNamecolumns to your database - Populate them from existing name data
- Deploy the schema change adding
firstNameandlastNamefields - Deprecate the name field
- After migration period, remove name from schema
- After another period, remove name column from database
This sequence ensures data exists in the new structure before clients start requesting it, preventing null values or errors.
Handle gradual rollouts
For changes affecting many clients, consider gradual rollouts. One approach some teams use is feature flags or progressive deployment.
export function getUserResolvers(context) {
return {
name: (user) => {
// Always available during deprecation period
return `${user.firstName} ${user.lastName}`;
},
firstName: (user) => user.firstName,
lastName: (user) => user.lastName
};
}This example keeps both old and new fields available simultaneously. The feature flag approach applies at the data layer — for instance, controlling when to start populating firstName and lastName from a new data source — rather than changing the schema shape per client.
When rolling out gradually, track errors and performance metrics for each cohort so you can roll back quickly if issues emerge before continuing the rollout.
Detect breaking changes automatically
Automated tools catch breaking changes before they reach production, reducing the risk of accidentally breaking clients.
See the tools and libraries page for available schema validation tools.
Integrate schema validation in CI
Run breaking change detection in your continuous integration pipeline on every pull request.
import { buildSchema, findBreakingChanges } from 'graphql';
import { readFileSync } from 'fs';
export async function checkSchemaChanges(currentSchemaPath, proposedSchemaPath) {
const currentSchema = buildSchema(readFileSync(currentSchemaPath, 'utf-8'));
const proposedSchema = buildSchema(readFileSync(proposedSchemaPath, 'utf-8'));
const breakingChanges = findBreakingChanges(currentSchema, proposedSchema);
if (breakingChanges.length > 0) {
console.error('Breaking changes detected:');
breakingChanges.forEach(change => {
console.error(`- ${change.type}: ${change.description}`);
});
process.exit(1);
}
console.log('No breaking changes detected');
}This example uses GraphQL’s built-in findBreakingChanges function to compare schemas. Note that it requires GraphQLSchema objects built with buildSchema(), not raw SDL strings. The check fails the build if breaking changes appear, forcing explicit acknowledgment before merging.
For more comprehensive schema diff capabilities, including dangerous change detection and CI integrations, consider using GraphQL Inspector, which builds on these primitives.
To implement automated detection, store your schema in version control and run breaking change detection on every pull request. Require manual approval for PRs containing breaking changes and document the reason for each breaking change in commit messages.
Compare against production traffic
Schema comparison alone doesn’t tell you if a change actually affects clients. Compare proposed changes against real client queries to identify true impact.
import { parse, visit } from 'graphql';
export async function analyzeFieldUsage(typeName, fieldName, queryLogs) {
const affectedQueries = queryLogs.filter(log => {
const doc = parse(log.query);
let found = false;
visit(doc, {
Field(node) {
if (node.name.value === fieldName) {
found = true;
}
}
});
return found;
});
return {
totalQueries: queryLogs.length,
affectedQueries: affectedQueries.length,
affectedClients: new Set(affectedQueries.map(q => q.clientId)).size,
usagePercentage: (affectedQueries.length / queryLogs.length) * 100
};
}This example parses query logs and walks the AST to find field references, avoiding false positives from simple string matching. For production use, consider tools like GraphQL Inspector that provide built-in usage analysis. Use this data to prioritize migration efforts and estimate impact.
When analyzing usage, collect at least 30 days of query data for representative samples and identify both the number of queries and number of unique clients affected. Check usage patterns and use this data to set realistic migration timelines.
For monitoring tools that help with usage tracking, see the monitoring resources page.
Document evolution decisions
Maintain clear records of schema changes, deprecations, and migration timelines so teams can reference them when planning client updates.
Maintain a changelog
Document all schema changes in a changelog that clients can reference. Include what changed, when it changed, and what clients should do.
# Schema changelog
## 2025-02-01
### Added
- `Product.availableIn` field returns locations where product is available
- `ProductSort.PRICE_ASC` and `ProductSort.PRICE_DESC` enum values
### Deprecated
- `Product.inStock` field - use `Product.availableIn` to check availability
### Removed
- `User.phoneNumber` field (deprecated 2024-11-01)
## 2025-01-15
### Added
- `Order.estimatedDelivery` field for delivery date estimates
### Changed
- `Order.status` now includes `CANCELLED` enum valueThis example shows a clear changelog format with additions, deprecations, and removals organized by date. Include specific migration guidance for deprecated elements.
When maintaining changelogs, publish them prominently in your documentation and send notifications when new changes appear. Archive old entries but keep them searchable for teams maintaining older clients.
Track deprecation status
Maintain a registry of all deprecated elements with their timelines and removal dates.
export const deprecations = {
'User.name': {
deprecatedDate: '2024-12-01',
reason: 'Use firstName and lastName instead',
migrationDeadline: '2025-02-01',
removalDate: '2025-02-15',
replacements: ['User.firstName', 'User.lastName'],
status: 'active' // active, migrating, or removed
},
'Product.inStock': {
deprecatedDate: '2025-01-01',
reason: 'Use availableIn to check specific locations',
migrationDeadline: '2025-03-01',
removalDate: '2025-03-15',
replacements: ['Product.availableIn'],
status: 'active'
}
};This example maintains structured deprecation metadata that tracks the full lifecycle from deprecation through removal. Update the status as migrations progress.
When tracking deprecations, make this information available through your documentation, your GraphQL schema via @deprecated directives, and programmatic APIs that client teams can query to audit their usage.
Schema Review
Establish review processes and automated validation to keep your GraphQL schema consistent, safe, and easy to evolve.