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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A second endpoint at /graphql/v2 | 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 | 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 | 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 | 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.
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.
Related how-tos
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
-
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@deprecatedtells 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 -
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