Skip to content

Versioning in GraphQL APIs — Complete Guide

DodaTech Updated 2026-06-28 6 min read

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

Does GitHub version its GraphQL API?

GitHub uses schema evolution, not endpoint versioning. They add fields and deprecate old ones with @deprecated.

Can I use REST-style versioning with GraphQL?

You could, but it defeats the purpose. GraphQL's design enables evolution without versioned endpoints.

How long should deprecated fields stay?

Until all known clients have migrated. For public APIs, keep deprecated fields for 12+ months.

What is the @deprecated reason format?

A human-readable string explaining why deprecated and what to use instead.

Can clients see which fields are deprecated?

Yes, through introspection. The __schema query returns deprecation status for fields.

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