# 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.

Source: https://voxgig.com/howto/deprecate-graphql-fields-instead-of-versioning

- Audience: api-producer
- Level: intermediate
- Verified: 2026-09-25
- Published: 2026-09-25

## 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](https://spec.graphql.org/September2025/#sec--deprecated), which asks for a
reason and formats it as Markdown.[^1]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A second endpoint at /graphql/v2](https://graphql.org/learn/governance-versioning/) | A rewrite so large that no field survives it | Two schemas to keep correct, two sets of resolvers, and clients that never migrate off the first | Anything short of a rewrite, which is nearly every change |
| [Apollo GraphOS schema checks](https://www.apollographql.com/docs/graphos/platform/schema-management/checks) | Your graph already reports operations to GraphOS and you want the check run against real traffic | A hosted service, and a default window of one week that you have to widen yourself | The traffic is not in GraphOS, or you need the check to run offline |
| [@deprecated with a reason and usage tracking](https://spec.graphql.org/September2025/#sec--deprecated) | Every field you retire, in any schema, with any tooling | The field stays live, and served, until the last client stops asking for it | Never; the other rows add to this one rather than replace it |
| [graphql-inspector diff in CI](https://the-guild.dev/graphql/inspector/docs/commands/diff) | You want the classification of every change on every pull request, with no service behind it | It knows the schemas and not the traffic, so the window and the usage check are yours to build | You 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.

```graphql title="schema.deprecated.graphql"
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.

```json title="deprecations.json"
{
  "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.

```bash
node diff.mjs
```

```text output
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](https://the-guild.dev/graphql/inspector/docs/commands/diff)
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.

```ts title="gate.mjs"
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.

```bash
node gate.mjs schema.deprecated.graphql schema.removed.graphql --today 2026-09-25
```

```text output
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.

```bash
node gate.mjs schema.deprecated.graphql schema.removed.graphql --today 2026-07-01
```

```text output
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.

```ts title="usage.mjs"
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.

```bash
node usage.mjs 2026-09-25
```

```text output
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](https://nodejs.org/api/test.html). The gate is
exercised from both sides of the window, and the usage count is pinned to the fixture.

```ts title="gate.test.mjs"
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'])
})
```

```bash
node --test gate.test.mjs
```

```text output
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](https://spec.graphql.org/September2025/#sec-Schema-Introspection.Deprecation),
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](https://graphql.org/learn/governance-versioning/) 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.

## Related how-tos

- [Implement Relay connections in a GraphQL schema](/howto/implement-relay-connections-in-a-schema)
- [Evolve an error contract without breaking clients](/howto/evolve-an-error-contract-without-breaking-clients)

## 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.

[^1]: The directive's definition in the
[September 2025 edition](https://spec.graphql.org/September2025/#sec--deprecated) 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.

[^2]: Apollo's
[schema checks page](https://www.apollographql.com/docs/graphos/platform/schema-management/checks)
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.