Free Ebook cover GraphQL API Design and Performance: Build Flexible Backends with Schemas, Resolvers, and Security

GraphQL API Design and Performance: Build Flexible Backends with Schemas, Resolvers, and Security

New course

21 pages

Versioning Without Breaking Clients Using Deprecation and Schema Evolution

Capítulo 9

Estimated reading time: 0 minutes

+ Exercise

Why GraphQL Versioning Looks Different

In REST, versioning often means creating a new URL or header-based version and running multiple APIs in parallel. In GraphQL, the schema is the contract, and clients select exactly the fields they need. That makes “big bang” version bumps less necessary, but it does not remove the need to evolve. The goal is to change the schema in a way that keeps existing client queries working while enabling new capabilities. The primary tools are deprecation, additive schema changes, and controlled schema evolution supported by tooling and governance.

“Versioning without breaking clients” in GraphQL usually means avoiding schema-breaking changes and instead introducing new fields and types, deprecating old ones, and eventually removing them only after you have evidence that clients no longer use them. This chapter focuses on how to do that systematically: how to plan changes, how to mark and communicate deprecations, how to migrate clients, and how to enforce safety with checks and observability.

What Counts as Breaking vs Non-Breaking in GraphQL

Non-breaking changes (safe by default)

Non-breaking changes are changes that do not invalidate existing queries. In practice, these are mostly additive or loosening changes. Common safe changes include adding new fields to existing object types, adding new object types, adding new enum values (with caveats), adding new optional arguments, and adding new input fields that are optional. These changes allow new clients to use new capabilities while old clients continue to query the same selection sets.

  • Add a field: type User { id: ID! displayName: String } (existing queries still work).
  • Add a new query or mutation field (existing operations still work).
  • Add an optional argument with a default behavior that matches the previous behavior.
  • Add an optional input field (nullable) so existing clients can omit it.

Breaking changes (avoid unless you can coordinate)

Breaking changes are changes that cause previously valid queries to fail validation or to behave incompatibly. Removing a field, renaming a field, changing a field’s type in an incompatible way, changing nullability from nullable to non-null, removing enum values, or changing input field requirements are typical breaking changes. Even changes that still validate can be “behavior-breaking” if they alter semantics (for example, changing the meaning of a status field or the units of a numeric value) without a new field name.

  • Remove or rename a field that clients query.
  • Change String to Int, or [T] to T, or change list element nullability in a way that invalidates expectations.
  • Change String to String! (clients may not handle nulls the same way, and resolvers must guarantee non-null).
  • Make an input field required (nullable to non-null) or remove an input field clients send.

Deprecation as the Primary Versioning Mechanism

How deprecation works in GraphQL

GraphQL has built-in support for deprecation via the @deprecated directive on fields and enum values. Deprecation does not remove the field; it marks it as discouraged and provides a reason string. Tooling (IDEs, code generators, schema explorers) can surface these warnings to developers. Deprecation is the bridge between “we need to change” and “we can’t break clients.”

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

type User {  id: ID!  fullName: String @deprecated(reason: "Use displayName; fullName will be removed after 2026-06-01")  displayName: String}

Deprecation should be treated as a lifecycle state, not a comment. A good deprecation includes: what to use instead, why it is changing, and a timeline or condition for removal. If you cannot commit to a date, state a policy like “removed after 90 days of zero usage” or “removed after all known clients migrate.”

Deprecate fields, not entire schemas

GraphQL encourages evolving within one schema rather than publishing /v1 and /v2 endpoints. Deprecating at the field level allows different clients to migrate at different speeds. This is especially important when you have multiple consumer teams or third-party clients. Instead of forcing everyone to upgrade, you allow gradual adoption while maintaining compatibility.

Schema Evolution Patterns That Avoid Breaking Clients

Pattern 1: Add a new field and deprecate the old field

This is the most common approach for renames, semantic changes, or improved modeling. You introduce a new field with the desired name or behavior, keep the old field, and mark the old field deprecated. The resolver for the old field can be implemented in terms of the new one to reduce duplication.

type Product {  id: ID!  priceCents: Int!  price: Float @deprecated(reason: "Use priceCents for integer currency; price will be removed after 2026-03-01")}

Step-by-step migration approach: first ship priceCents and keep price working; then update client queries and generated types to use priceCents; then monitor usage of price; finally remove price after the deprecation policy is satisfied.

Pattern 2: Introduce a new type and keep the old type for compatibility

Sometimes a field’s shape needs to change in a way that cannot be expressed as a simple rename. For example, you might want to replace a string address with a structured address object. You can add a new field returning the new type and deprecate the old scalar field.

type Order {  id: ID!  shippingAddress: String @deprecated(reason: "Use shippingAddressV2")  shippingAddressV2: Address}type Address {  line1: String  line2: String  city: String  region: String  postalCode: String  countryCode: String}

To keep behavior consistent, ensure shippingAddressV2 can be derived from the old data when possible, and document any cases where parsing is lossy. If the old field is derived from the new structured data, keep formatting stable to avoid surprising clients that still display the string.

Pattern 3: Add new arguments instead of changing existing ones

Changing argument types or semantics can break clients. Prefer adding a new argument and keeping the old one, then deprecating the old argument if your tooling supports it. GraphQL supports deprecation on arguments in the specification, but not all tooling surfaces it equally; still, it is useful for schema documentation and internal governance.

type Query {  searchProducts(query: String, term: String @deprecated(reason: "Use query") ): [Product!]!}

If you need a more complex search input, add a new field (or a new argument) that takes a new input object, rather than changing the existing argument type. Keep the old behavior intact until clients migrate.

Pattern 4: Prefer additive input evolution (and avoid making inputs stricter)

Input types are a common source of accidental breaking changes. Adding a new optional input field is safe; making an existing field required is breaking for any client that does not send it. If you need a new required concept, add a new input type and a new mutation field (or a new argument) that uses it, then deprecate the old mutation.

input CreateUserInput {  email: String!  name: String}input CreateUserInputV2 {  email: String!  profile: UserProfileInput!}type Mutation {  createUser(input: CreateUserInput!): User! @deprecated(reason: "Use createUserV2")  createUserV2(input: CreateUserInputV2!): User!}

This approach keeps old clients functioning while allowing new clients to adopt the stricter contract. It also makes the migration explicit and testable.

Pattern 5: Enum evolution with forward compatibility

Adding enum values is usually non-breaking at the schema level, but it can be behavior-breaking for clients that assume the enum is exhaustive. Many client code generators map enums to language enums, which can crash or throw when encountering an unknown value. To evolve enums safely, combine schema changes with client guidance and, when possible, design for unknowns.

Practical guidance: document that clients must handle unknown enum values; consider representing “open” categories as strings if the domain changes frequently; or provide an UNKNOWN value and ensure servers can return it when mapping from new internal states to older schemas. If you add a new enum value, coordinate with client teams to ensure their generated code can tolerate it (for example, using “unknown case” patterns).

Deprecation Lifecycle: A Practical Step-by-Step Process

Step 1: Define a deprecation policy and removal criteria

Before marking anything deprecated, define a policy that answers: how long deprecations live, how you measure usage, and who approves removals. A typical policy might be: “Deprecated fields remain for at least 90 days and are removed only after 30 consecutive days of zero usage across production traffic.” Another policy might be aligned to release trains: “Removed after two quarterly releases.” The key is predictability.

  • Minimum deprecation window (time-based).
  • Usage-based criteria (traffic-based).
  • Communication channels (changelog, schema registry notes, internal announcements).
  • Ownership (who can deprecate, who can remove).

Step 2: Implement the new capability as an additive change

Ship the new field/type/mutation in a way that does not change existing behavior. If the new field is meant to replace an old one, make sure it is complete enough that clients can migrate without losing functionality. If the old field is computed, consider computing it from the new source of truth to keep them consistent.

Step 3: Mark the old capability as deprecated with a high-quality reason

Add @deprecated with a reason that includes the replacement and the timeline. Avoid vague reasons like “deprecated” or “use new field” without naming it. If the replacement is not a 1:1 mapping, mention the difference (for example, “new field returns cents instead of float dollars”).

Step 4: Update documentation and examples to use the new API

Even if old fields remain, your docs should lead new development toward the new fields. Update sample queries, reference docs, and any “getting started” snippets. If you maintain a schema explorer, ensure deprecated fields are visually marked and that the replacement is easy to discover.

Step 5: Provide a client migration recipe

Clients often need more than “use field X.” Provide a concrete mapping: what to query now, how to transform data, and what edge cases to handle. For example, if you replace fullName with displayName, specify formatting rules and whether displayName can be null. If you replace a string with an object, provide an example query and a snippet showing how to render it.

# Oldquery {  user(id: "1") {    fullName  }}# Newquery {  user(id: "1") {    displayName  }}

Step 6: Measure usage of deprecated fields in production

To remove safely, you need evidence. Track field-level usage by inspecting incoming operations and recording which fields are selected. Many GraphQL servers can expose this via plugins or request instrumentation. At minimum, log: operation name, client identifier (if available), and deprecated fields used. Aggregate this into dashboards so you can answer: “Which clients still use this field?” and “Has usage dropped to zero?”

Practical tip: require clients to send an operation name and a client name/version header. This turns deprecation from guesswork into a manageable migration project because you can contact the remaining consumers with precise information.

Step 7: Enforce deprecation in CI for new client code

Deprecation warnings are easy to ignore unless you enforce them. Add checks in client build pipelines that fail when a query references deprecated fields. This prevents new usage from appearing while you are trying to eliminate old usage. If you cannot fail builds immediately, start with warnings and then ratchet to errors after a grace period.

Step 8: Remove deprecated fields only after criteria are met

When usage is zero (or all known clients have migrated), remove the field from the schema and delete the resolver code. Treat removal as a breaking change even if you believe nobody uses it; run schema checks against known persisted operations (if you use them) and communicate the removal in release notes. If you have external clients, consider a longer window or a “sunset” announcement.

Schema Checks and Change Management Tooling

Schema diffing and breaking-change detection

To evolve safely, you need automated detection of breaking changes. Use a schema diff step in CI that compares the proposed schema to the currently deployed schema and flags removals, incompatible type changes, and nullability tightening. This is especially important in teams where multiple developers can modify the schema. The diff should be part of the pull request process, not an afterthought.

Even when you intend to make a breaking change (for example, removing a long-deprecated field), the diff tool provides a clear, auditable record and prevents accidental breakage elsewhere in the schema.

Operation safelists and persisted operations as a safety net

If your production environment uses persisted operations (or an operation safelist), you can validate schema changes against the set of operations that are actually executed. This is powerful for removals: you can prove that no persisted operation references a deprecated field. If you do not use persisted operations, you can still approximate this by collecting operation signatures from logs and validating them in CI.

Handling Semantic Versioning and Release Communication

Semantic versioning for a single evolving schema

Even without multiple endpoints, you can still apply semantic versioning to your schema artifact or graph registry. A common approach: increment a minor version for additive changes and a major version for removals or incompatible changes. Deprecations themselves are usually minor changes, but they should be highlighted in changelogs because they start a countdown to removal.

What matters most is that clients can subscribe to change notifications and understand the impact. Provide a changelog entry that includes: what changed, who is affected, how to migrate, and by when.

Communicating deprecations to different client types

Internal clients can often migrate quickly with direct coordination. External clients need more structured communication: public changelogs, email notifications, and longer deprecation windows. For mobile apps, consider app store update cycles; you may need to keep deprecated fields longer to support older app versions still in use.

Advanced Evolution Scenarios

Splitting a field into multiple fields

If a field is overloaded (for example, name contains both given and family name), you can add givenName and familyName, keep name as a derived concatenation, and deprecate it. Provide clear rules for formatting and localization. If concatenation is ambiguous, document that name is legacy and may not round-trip perfectly.

Merging fields or changing normalization

If you want to merge firstName and lastName into displayName, add displayName and keep the old fields. Do not remove the old fields until clients migrate. If you need to enforce new validation rules, implement them only on the new mutation or new input type, not by tightening the old one.

Changing nullability safely

Changing a field from nullable to non-null is breaking at the schema level and risky operationally because any unexpected null will propagate errors. If you want to guarantee non-null, introduce a new field with a non-null type (for example, displayName: String!) only if you can truly guarantee it, and keep the old nullable field as deprecated. Alternatively, keep the type nullable but document that it is “effectively non-null” and enforce it gradually with monitoring before making any schema-level guarantee.

Sunsetting entire features

Sometimes you need to remove a whole feature area. In GraphQL, you can deprecate the entry points (fields on Query and Mutation) and any related types that become unreachable. Because types can remain in the schema even if not referenced, ensure your removal plan includes cleaning up orphaned types after the last entry point is removed. Use schema diff tooling to confirm what becomes unreachable and what can be safely deleted.

Practical Example: Evolving a Checkout API Without Breaking Clients

Scenario and target change

Assume clients currently call createCheckout with a simple input and receive a total float. You want to introduce multi-currency support and return structured money values. Doing this in place would be breaking. Instead, you add a new mutation and new fields, then deprecate the old ones.

type Money {  amountCents: Int!  currencyCode: String!}input CreateCheckoutInput {  cartId: ID!}input CreateCheckoutInputV2 {  cartId: ID!  currencyCode: String!}type Checkout {  id: ID!  total: Float @deprecated(reason: "Use totalMoney")  totalMoney: Money!}type Mutation {  createCheckout(input: CreateCheckoutInput!): Checkout! @deprecated(reason: "Use createCheckoutV2")  createCheckoutV2(input: CreateCheckoutInputV2!): Checkout!}

Step-by-step rollout

First, deploy the new Money type, totalMoney field, and createCheckoutV2 mutation. Ensure the server can compute totalMoney for all checkouts, even those created via the old mutation. Second, update documentation and sample operations to use createCheckoutV2 and totalMoney. Third, mark total and createCheckout as deprecated with a clear reason and timeline. Fourth, track usage of deprecated fields and identify which clients still use them. Fifth, enforce “no deprecated fields” in new client builds. Finally, remove the deprecated mutation and field after the policy criteria are met.

This approach avoids breaking existing clients while enabling a significant domain change. It also keeps the schema readable: the “V2” naming is a pragmatic compromise when the change is large, but you still keep a single endpoint and a single evolving graph.

Now answer the exercise about the content:

Which approach best avoids breaking existing GraphQL clients when you need to change a field in an incompatible way?

You are right! Congratulations, now go to the next page

You missed! Try again.

GraphQL evolution typically stays within one schema: introduce new fields/types as additive changes, deprecate old fields with @deprecated, monitor usage, and remove only after clients have migrated.

Next chapter

Eliminating the N+1 Problem with Batching and DataLoader Patterns

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.