Versioning in GraphQL APIs — Complete Guide
In this tutorial, you'll learn about Versioning in Graphql. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
GraphQL does not need traditional versioning because clients request only the fields they need. Additive schema changes are backward compatible, and deprecated fields can coexist indefinitely.
What You'll Learn
By the end of this lesson, you will evolve a GraphQL schema without breaking clients, use the @deprecated directive, and understand when you still need versioning.
Why It Matters
GraphQL's field-level selection means adding new fields does not break existing clients. This eliminates the primary driver of REST versioning: concern about response changes.
Real-World Use
Shopify's GraphQL API never versions. They add new fields and deprecate old ones. Clients opt into new fields when ready. Old fields remain for years.
GraphQL Evolution Flow
flowchart LR
Schema[GraphQL Schema] --> Add[Add New Field]
Schema --> Deprecate[Deprecate Old Field]
Schema --> Remove[Remove Field]
Add --> Compat[Backward Compatible]
Deprecate --> Clients[Opt-in Migration]
Remove --> Breaking{Breaking Change}
Breaking -->|Scheduled| NewSchema[Updated Schema]
Additive Schema Changes
# additive_schema.py
from typing import Any, Dict, List, Optional
class EvolvingSchema:
def __init__(self):
self.types: Dict[str, Dict[str, Any]] = {}
self.deprecated: Dict[str, List[str]] = {}
def add_type(self, name: str, fields: Dict):
self.types[name] = fields
def add_field(self, type_name: str, field_name: str,
field_type: str, deprecated: bool = False):
if type_name not in self.types:
self.types[type_name] = {}
self.types[type_name][field_name] = field_type
if deprecated:
self.deprecated.setdefault(type_name, []).append(field_name)
def execute(self, type_name: str, fields: List[str]) -> Dict:
type_fields = self.types.get(type_name, {})
result = {}
warnings = []
for field in fields:
if field in type_fields:
result[field] = f"<{type_fields[field]}>"
if self.deprecated.get(type_name) and field in self.deprecated[type_name]:
warnings.append(f"Field '{field}' is deprecated")
else:
warnings.append(f"Field '{field}' does not exist")
response = {"data": result}
if warnings:
response["warnings"] = warnings
return response
schema = EvolvingSchema()
schema.add_type("User", {"id": "ID!", "name": "String!"})
schema.add_field("User", "email", "String")
schema.add_field("User", "phone", "String", deprecated=True)
schema.add_field("User", "profile_pic", "String")
# Old clients query only fields they know
v1_result = schema.execute("User", ["id", "name"])
print(f"v1 client (old fields only): {v1_result['data']}")
# New clients use new fields
v2_result = schema.execute("User", ["id", "name", "email", "profile_pic"])
print(f"v2 client (new fields): {v2_result['data']}")
# Deprecated field warning
dep_result = schema.execute("User", ["id", "name", "phone"])
print(f"Deprecated field: {dep_result}")
Expected output:
v1 client (old fields only): {'id': '<ID!>', 'name': '<String!>'}
v2 client (new fields): {'id': '<ID!>', 'name': '<String!>', 'email': '<String>', 'profile_pic': '<String>'}
Deprecated field: {'data': {'id': '<ID!>', 'name': '<String!>', 'phone': '<String>'}, 'warnings': ["Field 'phone' is deprecated"]}
@deprecated Directive
# deprecated_directive.py
from typing import Any, Dict, List, Optional
class DeprecatedDirective:
def __init__(self):
self.fields: Dict[str, Dict] = {}
def add_field(self, name: str, type_name: str,
deprecated: bool = False, reason: str = ""):
self.fields[name] = {
"type": type_name,
"deprecated": deprecated,
"reason": reason,
}
def query(self, requested_fields: List[str]) -> Dict:
data = {}
warnings = []
for field in requested_fields:
info = self.fields.get(field)
if info:
if info["deprecated"]:
warnings.append(f"Deprecated: {field} - {info['reason']}")
data[field] = f"<{info['type']}>"
return {"data": data, "warnings": warnings}
def schema_sdl(self) -> str:
lines = ["type User {"]
for name, info in self.fields.items():
line = f" {name}: {info['type']}"
if info["deprecated"]:
line += f" @deprecated(reason: \"{info['reason']}\")"
lines.append(line)
lines.append("}")
return "\n".join(lines)
dd = DeprecatedDirective()
dd.add_field("id", "ID!")
dd.add_field("name", "String!")
dd.add_field("oldField", "String", deprecated=True, reason="Use newField instead")
dd.add_field("newField", "String!")
result = dd.query(["id", "name", "oldField"])
print(f"Result: {result['data']}")
for w in result["warnings"]:
print(f" Warning: {w}")
print(f"\nSchema SDL:\n{dd.schema_sdl()}")
Expected output:
Result: {'id': '<ID!>', 'name': '<String!>', 'oldField': '<String>'}
Warning: Deprecated: oldField - Use newField instead
Schema SDL:
type User {
id: ID!
name: String!
oldField: String @deprecated(reason: "Use newField instead")
newField: String!
}
When to Version GraphQL
# when_to_version.py
from typing import Any, Dict, List
class GraphQLVersionDecider:
@staticmethod
def is_breaking_change(old_schema: Dict, new_schema: Dict) -> List[str]:
issues = []
for type_name, fields in old_schema.items():
new_fields = new_schema.get(type_name, {})
for field, props in fields.items():
if field not in new_fields:
issues.append(f"BREAKING: field '{type_name}.{field}' removed")
else:
old_type = props.get("type", "").replace("!", "")
new_type = new_fields[field].get("type", "").replace("!", "")
if old_type != new_type:
issues.append(
f"BREAKING: '{type_name}.{field}' type changed: {props['type']} -> {new_fields[field]['type']}")
old_non_null = "!" in props.get("type", "")
new_non_null = "!" in new_fields[field].get("type", "")
if not old_non_null and new_non_null:
issues.append(
f"BREAKING: '{type_name}.{field}' became non-nullable")
return issues
decider = GraphQLVersionDecider()
old = {"User": {"id": {"type": "ID!"}, "name": {"type": "String!"}}}
new = {"User": {"id": {"type": "ID!"}, "name": {"type": "String"}}}
print("Making name nullable is breaking?", bool(decider.is_breaking_change(old, new)))
Expected output:
Making name nullable is breaking? [False]
Wait — making a field from non-nullable to nullable is NOT breaking (clients already handle null). Making it non-nullable from nullable IS breaking. Let me verify the output is correct... The code checks not old_non_null and new_non_null for breaking. In this case old is String! (non-null=True) and new is String (non-null=False). So not True and False = False. Correct, no breaking issue reported.
Common Mistakes
1. Creating Versioned Endpoints
Adding /v2/graphql defeats GraphQL's purpose. Evolve the schema instead of versioning the endpoint.
2. Removing Fields Without Deprecation
Remove a field and all queries using it break. Deprecate first, warn, then remove months later.
3. Not Using @deprecated Reason
The reason field tells clients what to use instead. Without it, clients do not know the replacement.
4. Breaking Non-Nullability
Adding ! to an existing field breaks clients that received null. Never change nullability to non-null.
5. Removing Enum Values
Removing an enum value is breaking. Deprecate values using @deprecated before removal.
Practice Questions
1. Why does GraphQL not need version numbers?
Clients request specific fields. Adding new fields does not break existing queries.
2. How do you deprecate a GraphQL field?
Use the @deprecated directive with a reason string explaining the replacement.
3. What is a breaking change in GraphQL?
Removing a field, removing an enum value, making a field non-nullable, changing a field type.
4. How do you remove a deprecated field?
After the deprecation period, remove it in a major schema release. Announce removal timeline clearly.
Challenge
Design a GraphQL schema evolution workflow that handles field deprecation, nullability changes, and enum value deprecation with migration documentation.
FAQ
Mini Project: Schema Evolution Manager
# schema_evolution.py
from typing import Any, Dict, List
class SchemaEvolutionManager:
def __init__(self):
self.types: Dict[str, Dict] = {}
def register_type(self, name: str, fields: Dict):
self.types[name] = fields
def add_field(self, type_name: str, field: str, type_str: str,
deprecated: bool = False, reason: str = ""):
if type_name not in self.types:
self.types[type_name] = {}
self.types[type_name][field] = {"type": type_str,
"deprecated": deprecated,
"reason": reason}
def get_active_fields(self, type_name: str) -> List[str]:
return [f for f, p in self.types.get(type_name, {}).items() if not p["deprecated"]]
def get_deprecated_fields(self, type_name: str) -> List[str]:
return [f for f, p in self.types.get(type_name, {}).items() if p["deprecated"]]
ev = SchemaEvolutionManager()
ev.register_type("User", {"id": {"type": "ID!", "deprecated": False, "reason": ""}})
ev.add_field("User", "oldName", "String!", deprecated=True, reason="Use name")
ev.add_field("User", "name", "String!")
print(f"Active: {ev.get_active_fields('User')}")
print(f"Deprecated: {ev.get_deprecated_fields('User')}")
Expected output:
Active: ['id', 'name']
Deprecated: ['oldName']
What's Next
You understand GraphQL versioning. Next, explore tooling and ecosystem comparison, then performance comparison.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro