How-to › Integrate beyond REST

How to retire a GraphQL field without versioning the schema#

Deprecate the field with a reason naming its replacement, count per-client usage over a release cycle, and let a CI gate hold the removal until the window passes.

Audience
API producer
Level
intermediate
Topic
Integrate with GraphQL
Verified

User.name has a replacement, fullName, and you want it gone. There is no /v2 to move the schema to, and deleting the field fails every client that still selects it with a validation error on the whole operation. Your dashboards say nobody uses it, and you do not know whether the dashboards are right.

What you get

You will end up with a deprecation that names its replacement, a per-client count of who selects the field, and a CI gate that holds the removal until the window has passed. This is for you if you own a schema other teams query.

Short answer

Add @deprecated(reason: "Use fullName.") to the field and record the date in a ledger the build can read. Count selections of the field per client from your operation logs over a whole release cycle, not a week. Run graphql-inspector’s diff against the previous schema in CI, and fail any breaking change except the removal of a field that has been deprecated for the stated window. Then delete it.

You will need

Node 22 or later, a schema that is already in production, and a log of the operations your clients send that carries the client name and version on each line. Verified 2026-09-25 against Node 22.22.2, graphql 17.0.2, and @graphql-inspector/core 8.0.0. The directive itself is defined by the GraphQL specification, which asks for a reason and formats it as Markdown.1

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A second endpoint at /graphql/v2A rewrite so large that no field survives itTwo schemas to keep correct, two sets of resolvers, and clients that never migrate off the firstAnything short of a rewrite, which is nearly every change
Apollo GraphOS schema checksYour graph already reports operations to GraphOS and you want the check run against real trafficA hosted service, and a default window of one week that you have to widen yourselfThe traffic is not in GraphOS, or you need the check to run offline
@deprecated with a reason and usage trackingEvery field you retire, in any schema, with any toolingThe field stays live, and served, until the last client stops asking for itNever; the other rows add to this one rather than replace it
graphql-inspector diff in CIYou want the classification of every change on every pull request, with no service behind itIt knows the schemas and not the traffic, so the window and the usage check are yours to buildYou already pay for a registry that sees the operations

The first row is what REST reaches for, and GraphQL’s own guidance argues against it. A parallel schema is two things to keep correct, for clients who will move when it suits them. Deprecation keeps one schema and carries the dead field until usage stops, which is slower and cheaper. The two check rows differ in what they can see. inspector sees the two schemas and classifies the change; GraphOS sees the operations and can say who breaks. The gate below gives inspector the piece it lacks, a window, from a file you commit.

Mark the field and start the clock

The deprecated schema, with a reason that tells the client what to select instead.

type User {
  id: ID!
  name: String @deprecated(reason: "Use `fullName`.")
  fullName: String!
  email: String!
}

The schema does not know when that line was added. Git does, but a gate that reads git history is slow and fragile, so the date goes in a file beside the schema, keyed by the field.

{
  "User.name": { "since": "2026-06-01", "reason": "Use `fullName`." }
}

What graphql-inspector says about each step, from the programmatic diff in @graphql-inspector/core. The CLI prints the same classification.

node diff.mjs
graphql 17.0.2 | @graphql-inspector/core 8.0.0

add @deprecated
  NON_BREAKING  FIELD_DEPRECATION_ADDED  User.name.@deprecated
  NON_BREAKING  DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED User.name.@deprecated
  NON_BREAKING  DIRECTIVE_USAGE_ARGUMENT_ADDED User.name.@deprecated.reason
remove the deprecated field
  BREAKING      FIELD_REMOVED            User.name
the same removal, suppressRemovalOfDeprecatedField
  DANGEROUS     FIELD_REMOVED            User.name
remove a field that was never deprecated
  BREAKING      FIELD_REMOVED            User.name

Deprecating is non-breaking, and removing is breaking whether or not the field was deprecated first. That is the correct reading: a client selecting the field breaks either way. The suppressRemovalOfDeprecatedField rule demotes the removal to dangerous, so the CLI exits zero, and the documentation says it exists so that such a removal will not fail a check. It carries no notion of how long the field was deprecated, which is what the next section adds.

Gate the removal on the window

The gate runs inspector’s diff, then applies one rule of its own.

export async function checkGate({ previous, next, ledger, today, windowDays = DEFAULT_WINDOW_DAYS }) {
  const before = buildSchema(previous)
  const changes = await diff(before, buildSchema(next))
  const wasDeprecated = deprecatedFields(before)
  const failures = []
  const notes = []

  for (const c of changes) {
    if (c.type === 'FIELD_DEPRECATION_ADDED') {
      const field = c.path.replace(/\.@deprecated$/, '')
      if (!ledger[field]) failures.push(`${field}: deprecated without a ledger entry`)
      else notes.push(`${field}: deprecated, on the ledger since ${ledger[field].since}`)
      continue
    }
    if (c.criticality.level !== CriticalityLevel.Breaking) continue
    if (c.type !== 'FIELD_REMOVED') {
      failures.push(`${c.path}: ${c.message}`)
      continue
    }
    const entry = ledger[c.path]
    if (!wasDeprecated.has(c.path) || !entry) {
      failures.push(`${c.path}: removed without a deprecation on record`)
      continue
    }
    const age = Math.round((Date.parse(today) - Date.parse(entry.since)) / DAY)
    // NaN compares false against everything, so an unreadable date would pass.
    if (!Number.isFinite(age)) failures.push(`${c.path}: unreadable date, since ${entry.since}, today ${today}`)
    else if (age < windowDays) failures.push(`${c.path}: deprecated ${age} days ago, the window is ${windowDays}`)
    else notes.push(`${c.path}: removed after ${age} days deprecated`)
  }
  return { ok: failures.length === 0, failures, notes, changes: changes.length }
}

Three decisions are in there. A deprecation with no ledger entry fails, so the clock cannot be forgotten. A date that does not parse fails too, rather than producing an age of NaN that no comparison catches. And a removal is checked against the deprecationReason the previous schema carries, not only the ledger, so a ledger entry typed in the same pull request as the deletion buys nothing.

--today exists so the gate can be tested and so this page reproduces. In CI, leave it out and the gate uses the clock.

node gate.mjs schema.deprecated.graphql schema.removed.graphql --today 2026-09-25
schema.deprecated.graphql -> schema.removed.graphql: 1 changes, window 90 days, today 2026-09-25
  ok    User.name: removed after 116 days deprecated
gate: pass

The same removal proposed too early exits non-zero and says by how much.

node gate.mjs schema.deprecated.graphql schema.removed.graphql --today 2026-07-01
schema.deprecated.graphql -> schema.removed.graphql: 1 changes, window 90 days, today 2026-07-01
  FAIL  User.name: deprecated 30 days ago, the window is 90
gate: fail

In CI, the previous schema is the one on the main branch and the next is the pull request’s. Both are files, so the gate needs no running server and no network.

Count who still selects it, over a release cycle

The window is the minimum. The field goes when usage reaches zero, and zero has to be measured over a period long enough to include every client build that is still in the field.

export function fieldUsage(schema, lines, { asOf, days }) {
  const typeInfo = new TypeInfo(schema)
  const to = Date.parse(asOf)
  if (!Number.isFinite(to)) throw new Error(`unreadable date: ${asOf}`)
  const from = to - days * DAY
  const usage = new Map()
  lines.forEach(({ day, client, count, query }, i) => {
    const at = Date.parse(day)
    // NaN compares false against both ends of the window, so an unreadable day would count in every window.
    if (!Number.isFinite(at)) throw new Error(`line ${i + 1} has an unreadable day: ${day}`)
    // A null count would add nothing and a string would concatenate: an undercount either way.
    if (!Number.isInteger(count) || count < 0) throw new Error(`line ${i + 1} has an unreadable count: ${count}`)
    if (at < from || at > to) return
    let doc
    try {
      doc = parse(query)
    } catch (err) {
      // A line that is skipped is an undercount, and an undercount here removes a field early.
      throw new Error(`line ${i + 1} does not parse: ${err.message}`)
    }
    visit(doc, visitWithTypeInfo(typeInfo, {
      Field() {
        const parent = typeInfo.getParentType()
        const field = typeInfo.getFieldDef()
        if (!parent || !field) return
        const key = `${parent.name}.${field.name}`
        const byClient = usage.get(key) ?? new Map()
        byClient.set(client, (byClient.get(client) ?? 0) + count)
        usage.set(key, byClient)
      },
    }))
  })
  return usage
}

TypeInfo resolves each selection to a type and field, so an alias or a fragment still counts against User.name. A string search of the query text for name would not, and would also count every other type’s name. The log here is a fixture, traffic.jsonl, with one line per client, day, and operation.

node usage.mjs 2026-09-25
User.name selections up to 2026-09-25
  last  7 days      0  none
  last 30 days     20  ios/3.2 20
  last 90 days    418  ios/3.2 418

A week says nobody. A quarter says an iOS build from before the deprecation is still asking, less each month, because its users are updating on their own schedule and not on yours. Measure over the longest gap between a client release and the last user leaving it, which for a mobile app is months. The Apollo check’s default window is one week, which its documentation states beside the setting that changes it.2

Check it worked

Eleven tests, with the Node test runner. The gate is exercised from both sides of the window, and the usage count is pinned to the fixture.

test('removing a deprecated field inside the window fails and says by how much', async () => {
  const r = await checkGate({ previous: DEPRECATED, next: REMOVED, ledger: LEDGER, today: '2026-07-01' })
  assert.equal(r.ok, false)
  assert.deepEqual(r.failures, ['User.name: deprecated 30 days ago, the window is 90'])
})

test('removing a deprecated field after the window passes', async () => {
  const r = await checkGate({ previous: DEPRECATED, next: REMOVED, ledger: LEDGER, today: '2026-09-25' })
  assert.equal(r.ok, true)
  assert.deepEqual(r.notes, ['User.name: removed after 116 days deprecated'])
})
node --test gate.test.mjs
1..11
# tests 11
# suites 0
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 272.67393

The test to keep is the one where a field is removed after a deprecation that never reached the ledger. It fails with removed without a deprecation on record, which is the gate refusing to take a pull request’s word for how long the field has been marked. The one after it feeds the gate a ledger date it cannot read. NaN compares false against every window, so a gate that does arithmetic on dates has to refuse them rather than pass.

When it goes wrong

The gate passes and a client breaks anyway. The window elapsed but usage was read from a week of traces, and a mobile build older than the window was still shipping the query. Read usage over a release cycle, per client, and treat any non-zero row as a reason to wait.

The build fails on the deprecation itself. The ledger entry is missing, so the gate reports deprecated without a ledger entry. Add the entry in the same change as the directive. That is the point at which the date is known, and the only point at which it is cheap to record.

The reason says nothing useful. @deprecated with no argument gets the specification’s default text, which tells a client that the field is unsupported and not what to select instead. Name the replacement in the reason, because the reason is what tools such as GraphiQL show the client developer.

Clients report the field has vanished before you removed it. Introspection’s fields query takes includeDeprecated, and the specification defaults it to false, so a tool that introspects with the defaults no longer sees the field. That is the specification’s own advice to tools, that they respect deprecation through information hiding. It is also a migration you did not schedule, so tell the client teams before the directive lands, not after.

When not to do this

Do not stand up /graphql/v2 to avoid a deprecation. GraphQL’s own guide to schema change allows it for a major overhaul and says it sacrifices the benefits of a single schema and forces you to maintain several at once. A parallel schema is a second set of resolvers to keep correct, and the clients who did not migrate off the first one will not migrate off the second.

Do not enable suppressRemovalOfDeprecatedField as a standing rule. It turns every such removal into a warning, on the day the directive was added as much as a year later, and the window stops meaning anything.

Do not delete on the day the window closes if the usage count is not zero. The window is the floor, not the schedule. A client on an old build is still a client.

Do not put the deprecation date in the reason string to save the ledger. The reason is shown to client developers as guidance, and a date there is a promise you will be held to by people who cannot see the usage numbers.

Last verified

Verified 2026-09-25 against Node 22.22.2, graphql 17.0.2, and @graphql-inspector/core 8.0.0. Every output block is what the command preceding it printed. The Apollo GraphOS row is read from its documentation, not run.

Footnotes

  1. The directive’s definition in the September 2025 edition reads reason: String! = "No longer supported". The argument is required and has a default, which is the specification’s way of making the reason mandatory while letting everyone omit it. A field deprecated with a bare @deprecated tells the client only that it is no longer supported, in exactly those words, without naming what to ask for instead. The same section says the reason is formatted as Markdown, so the replacement can at least be set in code font. ↩︎ Back to text

  2. Apollo’s schema checks page gives the defaults for its operations check while explaining how to reduce their cardinality. The time range is “Within last week,” and an operation counts once it has run once. The same page caps a check at 10,000 distinct operations. A week is a sensible default for a service that has to bound its own work, and a short one for a field whose last caller is a phone. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.