Deprecating Fields Safely in a Federated Graph
Removing a field from a federated graph is easy; removing it without breaking a client you did not know about is the part that needs a process. The good news is that a federated graph gives you something a REST API never does — a record of exactly which operations select which fields — so “is anyone still using this?” is a question with an answer rather than a guess. This page covers the full lifecycle from deprecation to deletion. It sits under Migrating and Versioning Federated Schemas.
When to use this pattern
- Use it for every field removal, including ones you are confident nobody uses, because the confidence is what usage data repeatedly disproves.
- Use it when a field is being replaced rather than simply dropped, since the overlap is what lets clients migrate on their own schedule.
- Skip the wait only for a field that has never been published, which is the one case where nobody can be depending on it.
Prerequisites
Four phases, and the one that cannot be rushed
Phase one — deprecate. Add @deprecated with a reason that names the replacement and a date. The field still works; tooling now tells clients it is going away. This phase costs nothing and can happen the moment the replacement exists.
Phase two — migrate. Clients move to the replacement at their own pace. Your job here is not to write code but to make the migration obvious: the deprecation reason should say exactly what to use instead, in enough detail that nobody has to ask.
Phase three — wait. Usage falls quickly at first, then flattens into a slow-decaying tail of infrequent callers: a quarterly batch job, a mobile build people have not updated, a partner integration nobody remembers. This phase ends when usage reaches zero and stays there, not when a date arrives.
Phase four — remove. Delete the field, publish, and let the check confirm nothing broke.
The third phase is the one teams compress, and it is the only one where compressing changes the outcome.
Implementation walkthrough
The deprecation itself is one directive, and the reason string does most of the work.
# orders subgraph
type Order @key(fields: "id") {
id: ID!
# A reason that names the replacement, the reason, and a date. Anything
# vaguer than this produces a support question instead of a migration.
legacyRef: String @deprecated(
reason: "Use `externalReference` instead — same value, validated format. Removal after 2026-09-01."
)
externalReference: ExternalRef
}
Where the field is being replaced rather than dropped, ship both and let the overlap do the work:
type Order @key(fields: "id") {
id: ID!
# Old and new coexist. Clients migrate one at a time, with no coordination.
legacyRef: String @deprecated(reason: "Use `externalReference`. Removal after 2026-09-01.")
externalReference: ExternalRef
}
// Resolve both from the same source so they cannot disagree during the overlap.
export const resolvers = {
Order: {
externalReference: (order: Order) => order.reference,
legacyRef: (order: Order) => order.reference?.value ?? null,
},
};
Resolving both from one source matters more than it looks. Two independent implementations during a months-long overlap will drift, and a client that migrates will see a value change — which reads as a bug in the new field and stalls everyone else’s migration.
When usage reaches zero, removal is a normal change and the check confirms it:
rover subgraph check my-graph@current --name orders --schema ./schema.graphql
# with usage at zero this reports no breaking changes and the removal proceeds
Verification steps
The whole process rests on one number, so make sure you are reading the right one.
# Usage must be read per variant. A public contract's consumers are not the
# source variant's, and zero on one says nothing about the other.
rover graph fetch my-graph@public > /dev/null # confirm the field is even present there
Check the usage window as well as the number. A seven-day window reports zero for a field last used five weeks ago by a monthly job, and the check will cheerfully call the removal safe. Set the window to at least one full cycle of your slowest known client, and longer if you have partners whose cadence you do not control.
Then, before deleting, run the check and read the output rather than the exit code. A green result with a warning about low-traffic usage is not the same as a green result with no usage at all, and the difference is exactly the client you are about to break.
Finally, after removal, watch for a spike in validation errors naming that field. A client still sending it now gets a request error rather than data, and that spike appearing at all means the waiting period was too short — useful information for the next deprecation even though it is too late for this one.
A deprecation reason is the only part of this process a client ever reads, so it is worth treating as an interface rather than as a comment. Three elements make it actionable.
Common mistakes & gotchas
A deprecation reason that says only “deprecated”. It gives a client no way to act, so the field stays in use and the removal date passes with the usage curve flat. Name the replacement and the date.
Reading usage on the wrong variant. A field can be unused internally and central to a partner contract. Check every variant that exposes it before concluding anything.
Two implementations during the overlap. Old and new resolving from different code will drift, and the drift lands on whoever migrated first — which is the worst possible group to punish.
Frequently Asked Questions
Does deprecation change composition at all?
No. @deprecated is metadata: the field composes, resolves and serialises exactly as before. What changes is that introspection, editors and usage reporting can all surface it, which is the entire mechanism. Treating deprecation as a functional change is the misconception that leads teams to skip it and go straight to removal.
Can a field be undeprecated?
Yes, and it is occasionally the right call — a field deprecated in anticipation of a replacement that never shipped should have its directive removed rather than lingering as a warning nobody can act on. What you cannot easily undo is removal, which is the reason the two are separated at all.
What about deprecating an argument or an enum value?
Both support @deprecated and both need the same discipline, with one wrinkle: usage data for arguments and enum values is coarser than for fields in most tooling, so the waiting period should be longer to compensate for a weaker signal. Removing an enum value has the additional coordination cost covered in handling enum value deprecation across subgraphs.
How does this interact with contracts?
Usefully. Because each variant has its own usage data, you can remove a field’s tag from a public contract long before it can be removed internally — retiring it from the audience that has already stopped using it while internal clients keep it. That decoupling is one of the better arguments for having contracts at all.
Who should own the removal, the field’s team or the graph’s?
The field’s team owns the decision and the graph’s owner owns the policy. In practice that means a subgraph team decides a field should go and writes the deprecation, while the published waiting period, the usage window and the escalation path for a client that will not migrate are set once for the whole graph. Splitting it that way avoids both failure modes: teams inventing their own timelines, and a central group becoming a bottleneck for every field anyone wants to retire.
What if a client refuses to migrate?
Escalate rather than extend indefinitely, because an unbounded wait teaches everyone that deprecation is advisory. The graph owner’s job here is to make the cost visible — the field is holding back a refactor, or an incompatible schema change, or a contract simplification — and to set a final date with the client’s own management rather than with its engineers. That conversation is much easier when the deprecation has been in place for months with a published date, which is another reason the reason string should always carry one.
Related
- Migrating and Versioning Federated Schemas — parent guide
- Migrating Field Ownership with the @override Directive — moving a field rather than removing it
- Handling Enum Value Deprecation Across Subgraphs — the enum equivalent
- Apollo Studio Schema Checks for Managed Federation — where the usage window is configured
- Contracts and Schema Variants for API Consumers — per-variant retirement