'GraphQL Doesn't Need Versioning' and Other Comforting Lies

Every GraphQL talk eventually arrives at the same slide: "GraphQL doesn't need versioning." The audience nods. The speaker looks pleased. And somewhere, a team is about to learn the hard way that "doesn't need versioning" is a bedtime story we tell each other to avoid discussing the actual versioning strategy GraphQL requires. Spoiler: there is one. It's just embedded into the schema instead of the URL.
Where The Myth Came From
The original GraphQL pitch included a compelling story. REST APIs version with URL paths (/v1/users, /v2/users), which means you either break v1 clients or you maintain two codebases forever. GraphQL's answer: never break clients. Add new fields, deprecate old ones, and let clients migrate at their own pace.
This is actually true, in a limited sense. You can avoid URL-based versioning in GraphQL. You never need /graphql/v2. What you do need is a rigorous discipline about schema changes, and that discipline is, for all practical purposes, a versioning strategy. We just gave it a different name to make it sound less like work.
"GraphQL doesn't need versioning" really means:
"GraphQL pushes versioning into the schema itself via additive-only changes and deprecation, instead of into the URL path."
That's it. That's the whole thing. If your team hears "no versioning" and thinks "we can change types whenever we want," you have a production incident in your future.
The Changes That Break Clients
Let's catalog every schema change that will ruin someone's day. Memorize this list. Print it. Tape it to your monitor.
Breaking Changes (Never Safe)
| Change | Why It Breaks |
|---|---|
| Removing a field | Client queries it, the query fails validation before execution starts |
| Renaming a field | Same as removing plus adding |
| Changing an output field from non-null to nullable | Clients that relied on a guaranteed value now receive null and crash (see The Nullability Trap) |
| Changing argument from optional to required | Existing queries without the argument fail validation |
| Removing an enum value | Queries and variables that send the value fail validation; response consumers lose a previously valid value |
| Changing a field's type (e.g., String to Int) | Serialization breaks, parsing fails |
| Removing a type | Any fragment on it fails validation |
| Removing an input field | Client sends it and fails validation |
| Changing default argument values | Silently changes behavior for clients relying on old default |
Additive Changes (Safe, or Merely Dangerous)
Note the second category. graphql.org's schema change management guidance splits non-breaking changes into genuinely safe ones and dangerous ones: changes that "appear safe but can cause subtle issues." That middle category is where most of the pain in this table lives, and collapsing it into "safe" is how teams get surprised.
| Change | Classification |
|---|---|
| Adding a field | Safe, but mind the resolver cost |
| Adding an optional argument | Safe |
| Adding a type | Safe |
| Adding a directive definition | Safe |
| Adding an enum value | Dangerous - see The Enum Value Trap below |
| Adding an interface implementation | Dangerous - existing fragments start matching new types |
| Changing an output field from nullable to non-null | Dangerous - see below |
That last row deserves its own note, because it is the one people assume is free. Tightening an output field from Float to Float! cannot break a query that already compiled, so it feels safe. Apollo's schema checks still report it, and they say why: the change is flagged "because validation does not have enough information to ensure that they are safe." Your CI will stop on it. Clients with generated types see number | null become number, which is usually harmless and occasionally is not. Ship it deliberately, not casually.
The Nullability Trap
Loosening an output field from non-null to nullable is the change teams most often talk themselves into believing is safe.
# v1
type Movie {
rating: Float!
}
# v2
type Movie {
rating: Float # now nullable
}
The argument for it going down easily is that no existing query becomes invalid. Every field the client selected still exists, with a compatible type, so nothing fails validation. The reasoning is correct as far as it goes, and it is also beside the point.
It is a breaking change, and this is not a matter of taste. graphql.org lists "making a non-null field nullable" among its common breaking changes, and Apollo's schema checks classify it as FIELD_CHANGED_TYPE and fail on it. What broke is not the query, it's the guarantee the query was written against:
- Client code doing
const display = movie.rating.toFixed(1)starts failing the moment a null arrives, at runtime, in production - Generated TypeScript types go from
numbertonumber | null, and every call site stops compiling - Apollo Client's normalized cache behavior may differ subtly
Worth being precise about the source, because this trips people up: the GraphQL specification does not classify schema changes as breaking or safe. It defines what makes a schema and a query valid, and it stops there. The breaking / dangerous / safe taxonomy comes from graphql.org's schema change management guidance and from the tools that implement it - Apollo's schema checks, GraphQL Inspector, Hive.
So "the spec says it's fine" is not an argument anyone can actually make. If you need to loosen a field, do it (see Nullable First), and give your clients the same warning you'd give before removing one.
The Enum Value Trap
Adding a value to an existing enum is the textbook dangerous change: additive on paper, and capable of breaking a client that was written before the value existed.
# v1
enum OrderStatus { PLACED, SHIPPED, DELIVERED }
# v2 (add a value)
enum OrderStatus { PLACED, SHIPPED, DELIVERED, RETURNED }
A v1 client receives an order with status RETURNED. The client's code might:
- Throw an exception ("unexpected enum value")
- Render an empty string
- Hit a switch statement's default case and show something unintended
- Break a TypeScript narrow
Apollo clients typically handle this gracefully if you configure them to. Naive clients don't. The contract you're shipping when you add an enum value is: "my clients must handle unknown enum values." Most schemas don't document this.
When you add an enum value, assume some fraction of your clients will break. Give them at least one release cycle of warning. In critical enums (payment statuses, order states), consider this a breaking change and treat it accordingly.
The Deprecation Strategy (The Actual Versioning)
Here's the mechanism that replaces URL versioning: @deprecated.
type User {
# Old field, marked deprecated
fullName: String @deprecated(reason: "Use firstName and lastName instead")
# New fields
firstName: String
lastName: String
}
Clients that query fullName still get data. New clients use the new fields. Your generated TypeScript types warn consumers. Your IDE underlines the deprecated field in tomato-red. Your team can see, in telemetry, how many clients still use the deprecated field.
The full lifecycle:
T0: Add new field(s), leave old field working
T0 + 2 weeks: Mark old field @deprecated, announce to clients
T0 + N months: Monitor usage. Hit zero? Or near-zero?
T0 + N months + grace period: Remove the field
The "grace period" is the real versioning decision. How long do you wait? That depends on:
- How fast your clients update
- Whether you control all the clients
- What contracts you promised
- How much you care about compatibility
For a public API, this might be a year. For a first-party mobile app with force-update capability, it might be a month. No universal answer applies; your organization picks its own deprecation SLA. That SLA is your versioning policy, even if you don't call it that.
The Organizational Reality
Here's what GraphQL "versioning" actually looks like in practice at a serious company:
-
Schema changes go through review. Every PR that touches
.graphqlsfiles gets scrutinized. Does this break anyone? Is it additive? Does it require a deprecation? -
Deprecated fields have owners and due dates. A spreadsheet (or better, a dashboard) lists every deprecated field, when it was deprecated, who owns the migration, and when it's safe to remove.
-
Breaking-change proposals require explicit sign-off. When you absolutely must break something (security issue, data model change that can't be additively modeled), you go through a change-management process that mirrors, almost exactly, the process a REST team would go through to ship a v2.
-
Schema registries enforce contracts. Tools like Apollo Schema Registry, Inigo, or Hive run against every PR to check for breaking changes automatically. Attempt to remove a field; CI fails.
-
Client usage telemetry decides the timeline. You don't remove a deprecated field because the calendar says so. You remove it when telemetry shows zero usage for 30 days.
Call this a versioning strategy or don't, but note that it has every component a formal versioning strategy would have: contracts, breaking-change policy, deprecation windows, migration tooling, usage tracking. The only thing we saved is the URL path.
The "Schema is Just One of Many Versions" Pattern
At sufficient scale, some organizations run multiple graph versions anyway. The federation supergraph changes. Old clients query one schema; new clients query a different schema with different fields. The router dispatches based on client version.
This is rare but not unheard of. Companies with decades-old iOS apps, enterprise integrations with fixed contracts, or regulated industries with audit requirements sometimes have two or three live schema versions at once.
Does this qualify as URL-style versioning? Not exactly, but the organizational complexity is similar. Any team doing this will tell you it's painful, and they avoid it when they can.
The Federation Complication
Federated schemas add another dimension. Each subgraph owner can change their subgraph. The composition of subgraphs into a supergraph can fail if changes are incompatible. One team's "non-breaking addition" can be another team's "wait, you added a conflicting type" error.
Federation composition checks become the versioning choke point:
# Apollo Router CI check
rover subgraph check my-graph@main \
--schema ./users.graphqls \
--name users
# Output:
# ✓ No breaking changes detected
# ! 3 warnings about potentially breaking client queries
In a federated world, you're not just avoiding breaking your own clients, you're also avoiding breaking the composition of your graph. Tooling like rover subgraph check and Apollo Studio's client usage reports become operationally essential.
The Part Nobody Writes Down
Here's the unpopular truth: the "no versioning" pitch only works if your team maintains high standards of schema discipline. When they don't, you end up with:
- Deprecated fields that sit there for years because nobody removes them
- Duplicate fields (
fullName,name,displayName) because nobody reconciled them - Schemas that balloon to 10,000 lines because every change was additive
- Ambiguity about which fields are "real" vs "being-sunset-eventually"
- Clients that use deprecated fields forever because there's no enforcement
Schemas become a kitchen drawer of good intentions. You can still use them, but nobody's proud of them.
Teams that manage this well treat schema changes as a first-class engineering concern. They staff it. They tool it. They measure it. Teams that don't eventually discover that "no versioning" was doing a lot of hand-waving over actual operational work.
Breaking Changes Happen. Plan For Them.
Sooner or later, every GraphQL API needs to make a breaking change. Schema redesigns. Security-motivated renames. Field semantics that were always wrong. You will face this.
The playbook:
-
Confirm it's actually necessary. Most "breaking" changes can be modeled additively. A new field called
displayName2is ugly but works. -
If you must break, announce. Email, Slack, blog post, whatever you use to reach clients. Include timeline, rationale, migration guide.
-
Deprecate the old thing for a long time first. Longer than you think is necessary. Clients are slower to update than you imagine.
-
Provide tooling for the migration. Example queries, codemods, a client-side migration assistant. Make the migration easy.
-
Cut over with a dual-schema window. For a period, support both old and new. Let clients switch at their own pace.
-
Remove the old thing only after telemetry confirms zero usage.
This is versioning, with the serial numbers filed off.
Honest Advice
Tell your team: GraphQL has a versioning strategy. It's embedded in how you change the schema. It's enforced through deprecation, schema registries, and migration tooling. It's a real thing you budget for, staff for, and commit to.
Don't tell them: "We're using GraphQL, so we don't need to think about API versions." They'll hear "we can change the schema whenever we want" and you'll spend the next year cleaning up client breakage.
The spec gave us the tools to version gracefully without URL paths. That's a real gift. It does not absolve us from versioning the things that actually matter: field contracts, client compatibility, deprecation cycles, and migration plans. Those remain as real as they ever were.
"GraphQL doesn't need versioning" is not a claim about avoiding work. It's a claim about where the work goes. It goes into the schema, the tooling around the schema, and the discipline of the teams that maintain the schema. When those are healthy, GraphQL's evolution story is genuinely better than REST's. When those are neglected, you get all the same problems REST teams have, plus a schema nobody wants to look at anymore.
Evolve your schemas with intention. That's the real pitch, and it's one worth believing.
This post is on version 1.0. Minor updates will be made non-breakingly. Breaking changes will require a deprecation window of at least one fortnight. Thank you for your understanding.
Sources
- GraphQL Spec (September 2025): Type System
- GraphQL Spec (September 2025): @deprecated Directive
- graphql.org: Schema Change Management - the breaking / dangerous / safe taxonomy this post uses
- Apollo: Schema Checks reference - how each change type is classified in practice
- Apollo Rover: subgraph commands
- Apollo Schema Registry
- The Guild: GraphQL Hive Schema Registry
- Principled GraphQL
- GraphQL Schema Design Best Practices