Skip to content
intermediate Phase 8 · Schema Design Patterns and Type System Depth

Deprecating Fields Safely

Using @deprecated annotations and usage data to remove fields without breakage.

40m
1 problems
Topic Progress 0%

A Deprecation Is a Runway, Not a Switch

A Deprecation Is a Runway, Not a Switch

GraphQL has one endpoint and no URL to version. When a field disappears, every client still selecting it gets an execution error — including mobile builds you cannot patch, partner integrations on quarterly releases, and a dashboard nobody has opened since last year.

So a graph evolves differently from a REST resource: a field is never removed in the release that replaces it. There is a runway, and each stage has a gate:

Stage Action Gate to the next stage
1. Replace add total alongside subtotal new field resolves correctly in production
2. Annotate @deprecated(reason:) on the old field docs, explorer, and codegen updated
3. Measure log which operations still select it zero external selections for a full window
4. Announce changelog, sunset date, response headers support confirms no known stragglers
5. Remove delete the field in a release breaking-change review passed

@deprecated is deliberately weak: the field stays fully executable, so nothing breaks while you measure. Its force is on tooling — GraphiQL and Explorer strike the field through, code generators warn or stop emitting it, and introspection exposes isDeprecated and deprecationReason to anything that asks.

type Order {
  "Prefer `total`; it includes tax and shipping."
  subtotal: Int! @deprecated(reason: "Use total. Removal planned for 2026-06-01.")
  total: Int!
}

The alternative — remove first, apologize later — is what makes teams afraid to touch their own schema. A predictable runway is why a well-maintained graph can change weekly without a version number: clients can plan because the contract includes how change happens, not just what the fields are named.

The runway also has a floor. A field deprecated with no removal date is a suggestion, and suggestions rot: within a year half the schema carries strikethrough and nobody knows which names are safe to build on.

Writing Reasons Clients Can Act On

Writing Reasons Clients Can Act On

A deprecation without a reason is a dead end: the client learns something is wrong and cannot act. The reason string is the only channel you have, because it travels through introspection to code you have never seen.

  • Bad: "Deprecated" — names no replacement and no date.
  • Better: "Use total instead" — actionable, still incomplete: which semantic difference, and when does it break?
  • Good: "Use total (includes tax and shipping). Removal planned 2026-06-01; see docs.example.com/orders/migration." — replacement, difference, date, and a place to read more.

Deprecation reaches further than object fields:

enum ShipmentStatus {
  PENDING IN_TRANSIT DELIVERED
  CANCELLED @deprecated(reason: "Use FAILED with reason CANCELLED_BY_CUSTOMER.")
}

input OrderFilter {
  status: ShipmentStatus
  "Matches a single instant only; prefer createdAt with a DateRange."
  shippedOn: Date @deprecated(reason: "Use createdAt: DateRange. Removal 2026-06-01.")
}

Enum values and input fields deserve the same care as fields. A value that vanishes from an enum breaks every client that still sends it in a variable, so annotate it in the release that introduces the replacement rather than at removal time. The reason should also state the semantic difference whenever the replacement is not a one-to-one swap — a client that swaps CANCELLED for FAILED without reading the note will silently change what its UI shows.

Clients discover it through introspection

{
  __type(name: "Order") {
    fields { name isDeprecated deprecationReason }
  }
}

Any client can enumerate every deprecated field in one request — which means a partner can build a report and a reviewer can hold your removal to the date you published. That accountability is the point of writing reasons carefully: you are writing to an audience you cannot talk to directly, and the string outlives the pull request that added it.

Fields with reasons also show up in schema reviews and generated documentation, so a precise reason doubles as the migration guide for the small, common case where a client just needs to know what to type instead.

Measuring Before You Remove

Measuring Before You Remove

Stage three is the one teams skip, and it is the only stage that tells you whether removal is safe. The measurement has two halves: which documents still mention the field, and which of those documents real clients actually send.

const usage = new Map(); // field name -> { documents: Set, clients: Set }

const deprecatedUsagePlugin = {
  onExecute({ document, contextValue }) {
    visit(document, {
      Field(node) {
        const name = node.name.value;
        if (!DEPRECATED.has(name)) return;
        const entry = usage.get(name) || { documents: new Set(), clients: new Set() };
        entry.documents.add(hashDocument(document));
        entry.clients.add(contextValue.clientId || 'anonymous');
        usage.set(name, entry);
      },
    });
  },
};

Counting document occurrences is cheap and runs on every request; tagging the client id tells you whether those selections come from your own web app — redeployable today — or from a partner integration that ships quarterly. When persisted queries are in use, the query id maps straight to a named client, which makes the report trivial and the conversation short.

Sizing the sunset window

  • Internal consumers: two weeks plus a changelog entry is usually enough.
  • Public API or partners: 30-90 days, announced by email and by response headers where you can add them.
  • Mobile apps you do not control: the oldest app version you still support plus app-store review lag — often a full quarter.

Remove only once the count of distinct external clients selecting the field has been zero for a complete window, and remove in one release with a breaking-change note, following the same rules as any other removal in schema evolution rules.

The failure on the other side

Deprecating and never removing is its own bug: a schema where half the fields are struck through, so the strikethrough stops meaning anything. Track a deprecated-fields report the way you track flaky tests — a count that only goes down — and give every annotation a removal date on the day you write it. Data plus a deadline is what keeps a runway from becoming a parking lot.

Practice Problems

0 / 1 solved
Plan the subtotal to total Deprecation
Schema Evolution Review

Order.subtotal must be replaced by Order.total, which includes tax and shipping. Given the current state — the field is still selected by your web app and two external partners — write the annotation, decide the stage you are in, and implement the gate that decides when removal is allowed.

Example:

Input: Telemetry shows web app selects subtotal in 4 queries; partner-a in 12; partner-b in 3; mobile stopped 60 days ago

Output: Stage 2 with stage 3 in progress: annotate, keep measuring, hold removal — external selections are 15, not 0

The gate counts distinct external clients, not total selections, so volume alone does not decide; two external consumers means the runway is not finished.

Optimal Solution — O(c) over c tracked clients time, O(c) for the usage ledger space

Annotate with a complete reason, keep the field executable, track distinct external clients per deprecated field from request documents, and let a gate function decide removal rather than a calendar reminder.

type Order {
  "Prefer total; it includes tax and shipping."
  subtotal: Int! @deprecated(reason: "Use total (includes tax and shipping). Removal 2026-06-01; see docs.example.com/orders/migration.")
  total: Int!
}

const REMOVAL_GATE_DAYS = 30;

function canRemove(usage, now) {
  const external = [...usage.clients].filter((c) => !INTERNAL.has(c));
  if (external.length > 0) return { remove: false, blockedBy: external };

  const lastSeen = Math.max(...[...usage.lastSeenByClient.values()]);
  const quietDays = (now - lastSeen) / 86_400_000;
  if (quietDays < REMOVAL_GATE_DAYS) {
    return { remove: false, blockedBy: 'waiting for full quiet window' };
  }
  return { remove: true };
}

Edge Cases:

  • A client that stops selecting the field but never appears again to prove it
  • Anonymous traffic with no client id — never counted as internal by default
  • The replacement itself later deprecated in the same release

Quiz

1. What does @deprecated do to a field at execution time?

Question 1 options

2. Usage logs show a deprecated field is still selected by two external partner integrations. What should happen next?

Question 2 options

3. What makes a deprecation reason useful to a client you have never spoken to?

Question 3 options

Flashcards

Question

The deprecation runway stages

Answer

Replace, annotate with @deprecated, measure usage, announce the sunset window, then remove in a breaking release.

Question

Effect of @deprecated on execution

Answer

None — the field still resolves. Introspection reports isDeprecated and deprecationReason, explorers strike it through, codegen warns.

Question

What a good reason string contains

Answer

The replacement field, the semantic difference if it is not one-to-one, and a removal date or migration link.

Question

What gates field removal

Answer

Zero distinct external clients selecting the field for a full sunset window, verified from query and document usage logs.

Question

Sizing a sunset window

Answer

Internal: ~2 weeks. Public API: 30-90 days. Mobile you do not control: oldest supported version plus store review lag, often a quarter.

Revision Notes

Key Takeaways

  • 1. @deprecated keeps the field executable so measurement can happen without breakage
  • 2. Every annotation carries a replacement, a semantic note, and a date — the string is the migration guide
  • 3. Removal is gated on usage data per client, not on elapsed time alone
  • 4. A deprecation with no removal date is schema rot; a deprecation with a date is a plan

Interview Tips

  • • Walk the five stages with the gate between each one — interviewers look for the measurement step, which most candidates skip
  • • Explain why GraphQL cannot version like REST (one endpoint, clients you do not deploy) and how the runway substitutes for a version
  • • Describe the instrumentation: visiting field nodes per execution, tagging client ids, and reporting distinct external consumers

Cheat Sheet

Deprecation Runway

Stages and gates

Stage Action Gate
1 add replacement field works in production
2 @deprecated(reason:) tooling and docs updated
3 log field usage per client zero external selections
4 announce date (changelog, headers) no known stragglers
5 remove in a breaking release review passed

Reason template: "Use (). Removal ; see ."

Deprecatable spots: object fields, enum values, input fields — annotate in the release that adds the replacement.

Windows: internal 2 weeks | public API 30-90 days | uncontrolled mobile = oldest version + store lag.

Gate: remove only when distinct external clients selecting the field hit zero for a full window.

Continue Learning