Skip to content
advanced Phase 11 · Error Handling, Nullability, and Schema Evolution

Evolving a Schema Without Breakage

Additive change rules that keep existing clients working across releases.

50m
1 problems
Topic Progress 0%

Your Newest Client Is Your Oldest Client

Your Newest Client Is Your Oldest Client

REST solves compatibility with a URL: /api/v1 stays up while clients migrate to /api/v2, sometimes for years. GraphQL has no URL to put a number on. There is one schema per endpoint, and the schema is the version.

That raises a precise question: what does it mean to break a client you cannot update? Not "did the server crash" and not "did the schema still start" — the definition that matters is:

An operation that validated against yesterday's schema must still validate against today's, produce the same type shape, and mean the same thing.

Three distinct violations hide in that sentence, and they fail at different times:

  1. Stops validating. The operation references a field or argument that no longer exists, or omits an argument that is now required. The client sees an errors entry from the validation phase — loud, immediate, but only when that operation runs.
  2. Validates, wrong shape. The operation still compiles against the schema, but a field's type or nullability changed underneath code that was generated from the old shape. Nothing fails until a runtime dereference hits a null that "cannot happen."
  3. Validates, different meaning. Types are untouched; the resolver's behavior or an argument's default value changed. Every affected operation keeps working and quietly returns something else — the failure class with the longest fuse.

The population you are protecting makes this hard. The mobile build shipped to app stores cannot be recalled; third-party integrations and cron scripts you have never seen keep sending operations; persisted-query registries hold documents from versions you no longer build. You are not coordinating a deploy — you are protecting every request recorded before your merge.

This is why the GraphQL ecosystem's answer to versioning is additive evolution: a client only depends on the fields it selects, so adding anything new is invisible to it, and the rest of the strategy is just discipline about removal. The rest of this topic is that discipline, plus the matrix that tells you which edits qualify as additive.

A Matrix for Every Schema Edit

A Matrix for Every Schema Edit

Run every proposed edit against this table before merge. "Safe" always means safe for operations that exist today, judged from the client's side of the wire.

Change Verdict Why
Add a field to an object type Safe Old operations never select it
Add a new type (object, input, enum, scalar, union) Safe Nothing references it yet
Add an optional argument (nullable or defaulted) Safe Existing operations omit it and still validate
Add a required argument with no default Breaking Every existing operation on that field fails validation: missing argument
Deprecate a field with @deprecated Safe The field still resolves; tooling only gains a warning
Remove a field or an argument Breaking Any operation passing it stops validating
Rename a field or a type Breaking A rename is a removal plus an addition
Output T! → T Breaking Clients compiled against non-null now receive null at runtime
Output T → T! Safe Clients already handle null; responses only get stronger
Input T → T! Breaking Operations that omit the value or pass null now fail coercion
Input T! → T Safe Existing arguments remain valid
Change to an unrelated type (String → Int) Breaking Values and results no longer match in either direction
Add an enum value (output position) Validation-safe, risky Exhaustive switch statements on the client have no arm for it
Remove an enum value Breaking Operations passing it stop validating
Change an argument's default value Validation-safe, behavior-changing Operations omitting it silently get new semantics
Add a possible type to a union Validation-safe, risky A fragment set that covered every member can now miss one

The asymmetry in the nullability rows is worth internalizing because interviewers probe it: output nullability may only tighten; input nullability may only loosen. Tightening an output from T to T! removes nulls from responses, which no client is unprepared for. Loosening an input from T! to T keeps old arguments valid while accepting more.

The bottom three rows are the ones that slip through a naive diff tool: they pass validation, so a compatibility checker says "fine," and only a human or usage data notices that behavior moved. Classification alone never catches category 3 — treat any edit to a default value or to resolver semantics as a review event, not a mechanical change.

One boundary: this matrix protects clients. Server-side code (resolvers, generated types) breaks on renames too, but that breakage is visible in your own build within minutes, so it never justifies holding a client-facing edit back.

Deprecate First, Measure, Then Remove

Deprecate First, Measure, Then Remove

The matrix says removal breaks clients. The follow-up question is how a field ever gets removed at all, and the answer is a runway with evidence at every stage.

Stage 1 — Mark it. @deprecated(reason: "Use reviewsConnection after the 2025-04 release") on the field. The field keeps resolving; GraphiQL strikes it through, docs list the reason, and generated client types can carry the warning to the developer who typed it (see the deprecation strategy topic for directive mechanics). The reason string is documentation for the next engineer, so write the replacement, not "old."

Stage 2 — Tell people. Changelog entry, client-team notice, a date you intend to remove. Deprecation without a date is a wish.

Stage 3 — Measure. The critical insight: @deprecated alone protects nobody — deprecated fields execute forever if clients keep sending them. Field-level usage telemetry turns the guess into a number: which fields were requested, by which registered operation, how recently. See the versionless API strategy topic for how that telemetry is collected and which client populations it cannot see.

Stage 4 — Gate the removal. A schema check in CI diffs the proposed schema and fails while the field still appears in active operations. Only when usage reads zero does the check go green and the deletion merge. The mechanics live in schema checks in CI; the point here is the ordering — enforcement comes after measurement, never instead of it.

Stage 5 — Remove and watch. Delete the field, ship, then watch validation-error rates spike for the first day. A non-zero rate means some client never reported usage (an unregistered third-party script, perhaps), and the data tells you which operation text is failing.

A worked timeline for a public field:

Week Action Evidence
0 @deprecated + changelog reason names the replacement
1-8 usage telemetry collected per-client request counts
8 removal PR opened check reports 0 requesting operations
9 merged, published validation-error rate flat for 7 days

Interview framing: "how do you remove a field from a live GraphQL API?" — deprecate with a reason, measure real usage, gate removal behind a CI check that reads operation data, then delete on a date. Mention that behavior-changing edits (defaults, resolver semantics) never show up in a diff and need human review, because that distinction separates a memorized answer from a worked one.

Practice Problems

0 / 1 solved
Classify a Schema Diff
Breaking-change triage

A release proposes six edits. Classify each as safe, breaking, or validation-safe-but-risky, and state which operations (if any) stop working and which ones keep working but behave differently.

Example:

Input: 1) Post.createdAt: String! added 2) Post.body removed 3) search(term: String, limit: Int = 25) limit default 10 -> 25 4) Role enum gains GUEST 5) author: Author! -> Author 6) search(term: String!, ...) adds required no-default arg page: Int

Output: 1 safe · 2 breaking (any operation selecting body fails validation) · 3 behavior-changing (pagination silently doubles) · 4 risky (exhaustive switches lack GUEST) · 5 breaking (output loosened) · 6 breaking (old calls lack page)

Each row exercises a different rule: additive, removal, default semantics, enum extension, output nullability direction, required-argument addition.

Optimal Solution — O(k) time, O(k) space

For each edit ask three questions in order: does any existing document stop validating (removal, rename, required argument, input tightening)? does the response shape change under generated clients (output loosening, type change)? does the same document now produce different data (defaults, enum semantics)? The first yes classifies it breaking, the third yes makes it behavior-changing, and no on all three means additive.

type Verdict = 'safe' | 'breaking' | 'behavior-changing' | 'risky';

const diff: [string, Verdict, string][] = [
  ['Post.createdAt: String! added',        'safe',             'no existing operation selects a field that did not exist'],
  ['Post.body removed',                    'breaking',         'operations selecting body fail validation'],
  ['search.limit default 10 -> 25',        'behavior-changing','omitted-argument callers get different pagination'],
  ['Role enum + GUEST',                    'risky',            'validates, but exhaustive client switches have no arm'],
  ['author: Author! -> Author',            'breaking',         'output loosened: null arrives where clients expect a value'],
  ['search + required page: Int',          'breaking',         'old operations never pass page: missing required argument'],
  ['PriceInput.currency: String! -> String','safe',            'input loosened: every existing caller still coerces'],
];

for (const [edit, verdict, why] of diff) {
  console.log(String(verdict).padEnd(18), edit, '::', why);
}

Edge Cases:

  • A rename where the old name is kept and deprecated (safe until removal)
  • Moving a field onto an interface its types already implement
  • Tightening an output while simultaneously relaxing a client-facing wrapper (verdicts do not cancel — evaluate each independently)

Quiz

1. Which change breaks an operation that exists today?

Question 1 options

2. A field changes from `price: Int!` to `price: Int` in a response type. What is the verdict?

Question 2 options

3. Why can adding an enum value still break clients even though every old operation validates?

Question 3 options

4. A PR changes an argument default from 10 to 25. All operations still validate. What kind of break is this?

Question 4 options

Flashcards

Question

Definition of a breaking schema change

Answer

An operation valid against yesterday's schema stops validating, changes type shape, or silently changes meaning.

Question

Nullability symmetry rule

Answer

Output types may only tighten (T → T!); input types may only loosen (T! → T). The reverse directions break clients.

Question

Additive-safe edits

Answer

New field, new type, new optional/defaulted argument, deprecation — nothing existing references them.

Question

Validation-safe vs behavior-safe

Answer

Changing a default or enum semantics passes validation but changes results — only review and telemetry catch it.

Question

Safe removal sequence

Answer

@deprecated with reason -> changelog + date -> usage telemetry at zero -> CI check green -> delete -> watch validation errors.

Question

Rename is

Answer

A removal plus an addition, therefore breaking — even though the total field count is unchanged.

Revision Notes

Key Takeaways

  • 1. Breaking means: stops validating, changes shape, or changes meaning — only the first is visible to a diff tool
  • 2. Additive edits are safe because operations only reference what they select
  • 3. Tighten outputs, loosen inputs; every other nullability move breaks someone
  • 4. Removal is a runway: deprecate, measure real usage, gate with CI, delete on a date

Interview Tips

  • • Give the matrix as a rule, not a memorized list: "safe = nothing existing references it," then derive rows on the spot
  • • Be ready for the asymmetric nullability question — it separates candidates who reasoned from candidates who read a checklist
  • • Describe the full deprecation lifecycle including how you learn that usage has reached zero

Cheat Sheet

Schema Evolution Cheat Sheet

Breaking (existing operations): remove field/argument · rename · add required argument (no default) · output T!→T · input T→T! · unrelated type change · remove enum value

Safe: add field · add type · add optional/defaulted argument · deprecate · output T→T! · input T!→T

Silently risky (validation passes):

  • default value change -> omitted args mean something new
  • new enum value / union member -> exhaustive client switches miss it
  • resolver semantics change -> same types, different data

Symmetry: outputs tighten only, inputs loosen only.

Removal runway:

@deprecated(reason: "use X") -> changelog + date
-> field usage telemetry = 0 requesting operations
-> CI schema check green -> delete -> watch 400s for a week

Enforcement: schema checks in CI (see schema checks).

Continue Learning