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:
- 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
errorsentry from the validation phase — loud, immediate, but only when that operation runs. - 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."
- 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
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?
2. A field changes from `price: Int!` to `price: Int` in a response type. What is the verdict?
3. Why can adding an enum value still break clients even though every old operation validates?
4. A PR changes an argument default from 10 to 25. All operations still validate. What kind of break is this?
Flashcards
Question
Definition of a breaking schema change
Click to reveal answer
Answer
An operation valid against yesterday's schema stops validating, changes type shape, or silently changes meaning.
Question
Nullability symmetry rule
Click to reveal answer
Answer
Output types may only tighten (T → T!); input types may only loosen (T! → T). The reverse directions break clients.
Question
Additive-safe edits
Click to reveal answer
Answer
New field, new type, new optional/defaulted argument, deprecation — nothing existing references them.
Question
Validation-safe vs behavior-safe
Click to reveal answer
Answer
Changing a default or enum semantics passes validation but changes results — only review and telemetry catch it.
Question
Safe removal sequence
Click to reveal answer
Answer
@deprecated with reason -> changelog + date -> usage telemetry at zero -> CI check green -> delete -> watch validation errors.
Question
Rename is
Click to reveal answer
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).