Siddhant Deval
Siddhant Deval
backend18 min read

Schema Evolution: Breaking Changes, Deprecation & Migration Windows

GraphQL schemas are append-only in practice — every breaking change has a client you don't control that will silently break. The discipline of schema evolution is procedural: measure usage, communicate windows, and remove only when zero clients remain.

Schema Evolution: Breaking Changes, Deprecation & Migration Windows

You own the schema and the resolvers — the schema is a public contract you can never silently break, and every resolver is a performance commitment you make on every query. The contract obligation means that every field you add to the schema is a commitment you must eventually manage off of — safely, verifiably, and without silently breaking any client that still depends on it. The most common GraphQL production incident is a breaking schema change that was not breaking at the SDL level: renaming a field from nullable to non-null, changing an input argument type, removing an enum value that a mobile client has cached in compiled code. These changes compile cleanly, pass your unit tests, and ship to production before you discover that 40,000 mobile clients can't render the order status screen.

Architectural Note

This is Part 7 of the GraphQL Backend & API Design series. It connects to Part 1 (Schema Design) for the non-null and @deprecated contract, and to Part 5 (Observability) for field usage analytics as the data source that makes safe removal possible.


1. Breaking vs. Non-Breaking Change Taxonomy

Not all schema changes are equal. Understanding exactly which changes are breaking is the prerequisite for safe schema evolution.

GRAPHQL
# Safe (non-breaking) changes — existing clients are unaffected:
# ✅ Adding a new optional field to an existing type
type User {
  id: ID!
  name: String!
  bio: String    # NEW: optional field, existing queries still valid
}

# ✅ Adding a new type
type Address { street: String! city: String! }

# ✅ Adding a new optional argument to a field
type Query {
  users(first: Int, after: String, sortBy: UserSortField): [User!]!
  #                               ^^^^^^^^^^^^^^^^^^^^^ new optional arg
}

# ✅ Marking a field @deprecated (it still executes)
type User {
  email: String @deprecated(reason: "Use primaryEmail. Removal: 2027-Q1.")
  primaryEmail: EmailAddress!
}
GRAPHQL
# Breaking changes — existing clients will fail silently or loudly:

# ❌ Removing a field — any client selecting it receives null with no warning
type User {
  # removed: legacyId: String
}

# ❌ Changing nullable to non-null — clients checking for null will misbehave
type User {
  phone: String     # was nullable
  phone: String!    # ❌ BREAKING: if phone is missing, null propagates up the tree
}

# ❌ Changing a field's type
type Order {
  total: Float!     # was Float
  total: Int!       # ❌ BREAKING: clients expecting decimal precision receive integers
}

# ❌ Adding a required argument (non-null, no default)
type Query {
  users(first: Int!): [User!]!  # ❌ existing clients sending 'users' without 'first' will fail
}

# ❌ Removing an enum value
enum OrderStatus {
  PENDING
  CONFIRMED
  # removed: PROCESSING  ← mobile clients with compiled switch statements will have unhandled case
}
Change Category Client Impact CI Detection
Add optional field None
Add non-null field Only if resolver returns null
Remove field Clients selecting it receive null (no error) rover graph check
Nullable → non-null Null propagation chain may silence data rover graph check
Remove enum value Compiled switch cases become unhandled rover graph check
Change field type Type mismatch at codegen boundary rover graph check
Add required argument Existing calls fail with validation error rover graph check

2. @deprecated Sunset Message Format

The @deprecated directive takes a reason string. That string is a human-and-machine-readable contract with three required pieces of information:

GRAPHQL
# ❌ Vague deprecation — provides no migration path or deadline
type User {
  email: String @deprecated(reason: "Use the new field")
}

# ✅ Complete deprecation — migration target + sunset deadline
type User {
  email: String @deprecated(reason: "Use `primaryEmail: EmailAddress!` instead. Removal planned: 2027-Q1. See https://wiki/graphql/email-migration.")
  primaryEmail: EmailAddress!
}

A complete @deprecated reason contains:

  1. Replacement field — the exact field name and type to migrate to
  2. Sunset deadline — a concrete quarter or date (not "soon")
  3. Migration guide — a link to the wiki, PR, or RFC where the migration is documented
TYPESCRIPT
// ✅ Machine-readable deprecation metadata via schema directives
// If your team needs programmatic access to deprecation metadata:
const schema = buildSchema(`
  directive @sunsetOn(date: String!, reason: String) on FIELD_DEFINITION

  type User {
    email: String
      @deprecated(reason: "Use primaryEmail. Removal: 2027-Q1.")
      @sunsetOn(date: "2027-01-01", reason: "Migrated to EmailAddress scalar")
    primaryEmail: EmailAddress!
  }
`);

3. CI-Enforced Breaking Change Detection

The gate that prevents breaking changes from reaching production is rover graph check in CI:

BASH
# In every subgraph CI pipeline, on every PR:

# Check if the schema change breaks any registered client operations
rover graph check my-graph@production \
  --schema ./schema.graphql \
  --name users-subgraph

# v0.20+ runs in under 10 seconds
# Outputs:
# ✅ No breaking changes detected
# 🔄 Deprecated field usage: User.email used by 847 operations
# ❌ BREAKING: Field 'User.legacyId' removed — 12 operations still using it

# --severity flag (v0.20+): set minimum severity to fail CI
rover graph check my-graph@production \
  --schema ./schema.graphql \
  --name users-subgraph \
  --severity BREAKING  # Only fail on BREAKING changes, not on DANGEROUS
BASH
# graphql-inspector can also run as a pre-commit hook:
npx graphql-inspector diff schema-old.graphql schema-new.graphql \
  --failOn BREAKING

# JSON diff output for GitHub PR checks:
npx graphql-inspector diff schema-old.graphql schema-new.graphql \
  --format json > schema-diff.json
Crucial Requirement

rover graph check compares your new schema against all registered client operation documents in Apollo GraphOS — not just against the schema itself. This is the critical distinction: nullable → non-null is only breaking if a client operation actually selects that field. If no client selects User.legacyPhone, making it non-null is safe. The check uses real operation data, not theoretical analysis.


4. The Phased Migration Playbook

Safe field removal follows a fixed five-phase process. Skipping any phase risks a production incident.

Phase 1: Add the Replacement Field

Add the new field alongside the existing one. Both are available in the schema simultaneously. Clients can migrate on their own schedule.

Phase 2: Deprecate with a Complete Reason

GRAPHQL
type User {
  email: String @deprecated(reason: "Use `primaryEmail: EmailAddress!`. Removal: 2027-Q1. Migration: https://wiki/graphql/email-migration")
  primaryEmail: EmailAddress!
}

Phase 3: Measure Field Usage

BASH
# GraphOS field insights — shows exact operation count and last-used date
rover graph field-usage my-graph \
  --field "User.email"

# Output:
# User.email
#   Total requests (last 30d): 23,847
#   Last requested: 2026-09-10
#   Affected operations: [GetUserProfile, UpdateNotifications, ...12 more]

Phase 4: Client Communication

Send a migration notice to each team owning an affected operation:

  • The specific operation name and client team
  • The target field and why it is better
  • The sunset deadline (hard date, not a quarter)
  • Offer to pair on the migration

Phase 5: Verify Zero Usage and Remove

BASH
# Confirm zero usage before removing
rover graph check my-graph@production --schema ./schema-without-email.graphql
# Must output: "✅ No operations affected by this change"

# Then deploy the schema without the field

5. Schema Versioning vs. HTTP Versioning

A common proposal when facing breaking changes is "why not just version the GraphQL API like REST?" (/v1/graphql, /v2/graphql). The answer is that schema versioning trades one problem for several worse ones:

Dimension Schema Evolution (additive + deprecate) HTTP Versioning (/v2/graphql)
Client migration Gradual — each client migrates independently Hard cutover — all clients must migrate before v1 is removed
Schema proliferation One schema, forward-compatible Multiple schemas, each requiring its own resolvers and tests
Breaking change safety rover graph check catches before deployment No automated gate — breaking changes only discovered after deployment
Field usage tracking Per-field analytics from APM No field-level visibility per version
Apollo Studio / codegen Single schema source of truth N schemas to maintain in tooling
Mental Model Check

GraphQL schemas evolve like law: you can always add new law (additive changes), you can mark old law as obsolete (deprecation), but you cannot repeal law that people are still relying on without a formal sunset process. The additive-only discipline is not a technical constraint — it is a contract management discipline.


Comparison Matrix: safe / dangerous / breaking changes across operations with client impact and CI detection status
Comparison Matrix: safe / dangerous / breaking changes across operations with client impact and CI detection status

Adding optional fields is always safe. Removing fields, changing types, and adding required arguments are breaking changes detectable by rover graph check before deployment.

Flow Trace: phased deprecation lifecycle — @deprecated annotation → measure usage → client communication → zero usage → rover graph check → removal
Flow Trace: phased deprecation lifecycle — @deprecated annotation → measure usage → client communication → zero usage → rover graph check → removal

The five-phase migration playbook: add replacement field → deprecate with sunset deadline → measure usage → contact affected teams → confirm zero usage → remove. No phase is optional.


Summary

Concept Rule
Schema is append-only Every field removal or type change is a breaking change in practice. The only safe evolution path is additive: add new fields, deprecate old ones, and remove only after confirmed zero usage.
@deprecated reason format Must contain: replacement field name, sunset deadline (hard date), and migration guide link. Vague reasons ("use the new field") are not actionable for client teams.
CI gate rover graph check runs in under 10 seconds and compares against real registered client operation documents — not just theoretical schema analysis. Make it a required CI check.
Phased removal Never remove a deprecated field without: (1) confirmed zero usage from GraphOS field insights, (2) a successful rover graph check with no affected operations, and (3) 24h error monitoring post-removal.
No HTTP versioning Schema versioning trades gradual migration for hard cutovers and multiplied resolver/test maintenance. The additive-only discipline is always the lower total cost.

What's Next

Part 8 — Testing GraphQL APIs: Resolvers, Integration & Schema Contracts closes the series by covering the testing pyramid: resolver unit tests with mocked context, integration tests via graphql() execute (no HTTP), schema contract tests with client operation documents, and CI validation with rover graph check.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#GraphQL#Schema Evolution#Breaking Changes#API Governance#CI/CD
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.