Skip to content

Versioning GraphQL APIs — Schema Evolution Without Breaking Clients

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you'll learn about Versioning Graphql. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

GraphQLs single-version approach handles evolution through schema deprecation, nullable fields, and client-driven selection rather than traditional API version numbers.

What You'll Learn

By the end of this lesson, you will manage GraphQL schema evolution without breaking clients, use deprecation directives, add fields as nullable, handle field removal, and migrate clients from deprecated fields.

Why It Matters

GraphQL clients specify exactly what they need, making many changes backward-compatible. Understanding this lets you evolve your schema without version bumps.

Real-World Use

Durga Antivirus Pro evolved its GraphQL schema from scan { id status } to scan { id state result { threats } } over 6 months, keeping the old status field as deprecated until all clients migrated.

GraphQL Schema Evolution

flowchart LR
    Schema[GraphQL Schema]-->Add[Add Field Nullable]
    Schema-->Deprecate[Deprecate Old Field]
    Schema-->Rename[Add Alias Field]
    Schema-->Remove[Remove After Migration]
    Add-->Client[Client Uses @skip]
    Deprecate-->Client2[Client Sees @deprecated]
    Rename-->Client3[Client Uses New Name]

Schema Evolution Manager

Track schema changes and deprecations.

from typing import Dict, List, Optional, Set
from datetime import datetime

class GraphQLSchemaEvolution:
    def __init__(self):
        self.fields: Dict[str, Dict] = {}
        self.deprecated: Dict[str, Dict] = {}

    def add_field(self, type_name: str,
                  field_name: str,
                  field_type: str,
                  nullable: bool = True,
                  description: str = ""):
        key = f"{type_name}.{field_name}"
        self.fields[key] = {
            "type": field_type,
            "nullable": nullable,
            "description": description,
            "added": datetime.utcnow(),
        }

    def deprecate_field(self, type_name: str,
                        field_name: str,
                        reason: str,
                        replacement: str = ""):
        key = f"{type_name}.{field_name}"
        if key in self.fields:
            self.deprecated[key] = {
                "reason": reason,
                "replacement": replacement,
                "deprecated_at": datetime.utcnow(),
            }

    def remove_field(self, type_name: str,
                     field_name: str):
        key = f"{type_name}.{field_name}"
        self.fields.pop(key, None)
        self.deprecated.pop(key, None)

    def get_schema_sdl(self, include_deprecated: bool = True
                       ) -> str:
        sdl = ""
        types = set(
            k.split(".")[0] for k in self.fields
        )
        for type_name in sorted(types):
            sdl += f"type {type_name} {{\n"
            for key, field in sorted(self.fields.items()):
                if key.startswith(f"{type_name}."):
                    field_name = key.split(".")[1]
                    nullable = field["nullable"]
                    ftype = field["type"]
                    if nullable:
                        ftype = ftype
                    sdl += f"  {field_name}: {ftype}"
                    if key in self.deprecated:
                        dep = self.deprecated[key]
                        sdl += f' @deprecated(reason: "{dep["reason"]}")'
                    sdl += "\n"
            sdl += "}\n\n"
        return sdl

    def get_active_queries(self) -> List[Dict]:
        return [
            {
                "type": k.split(".")[0],
                "field": k.split(".")[1],
                "type_name": v["type"],
                "deprecated": k in self.deprecated,
            }
            for k, v in sorted(self.fields.items())
        ]

evolution = GraphQLSchemaEvolution()
evolution.add_field("ScanResult", "id", "ID!", nullable=False)
evolution.add_field("ScanResult", "status", "String", nullable=True)
evolution.add_field("ScanResult", "state", "String", nullable=True)
evolution.deprecate_field("ScanResult", "status",
                          "Use state instead", "state")
sdl = evolution.get_schema_sdl()
print(sdl)

Client Query Compatibility

Check if client queries are compatible with the current schema.

from typing import Dict, List, Set, Optional
import re

class QueryCompatChecker:
    def __init__(self, schema_fields: Set[str]):
        self.schema_fields = schema_fields

    def extract_fields(self, query: str) -> Set[str]:
        fields = set()
        pattern = r'\b(\w+)\s*(?:\(|$)'
        for match in re.finditer(pattern, query):
            field = match.group(1)
            if field not in {"query", "mutation", "subscription",
                             "type", "on", "fragment", "..."}:
                fields.add(field)
        return fields

    def check_compatibility(self, query: str) -> Dict:
        used_fields = self.extract_fields(query)
        missing = used_fields - self.schema_fields
        return {
            "compatible": len(missing) == 0,
            "missing_fields": sorted(missing),
            "used_fields": sorted(used_fields),
        }

    def suggest_migration(self, query: str,
                          rename_map: Dict[str, str]
                          ) -> str:
        for old, new in rename_map.items():
            query = query.replace(old, new)
        return query

schema_fields = {"id", "state", "result", "threats"}
checker = QueryCompatChecker(schema_fields)
client_query = "{ scan { id status result } }"
result = checker.check_compatibility(client_query)
print(f"Compatible: {result['compatible']}")
print(f"Missing: {result['missing_fields']}")

Deprecation Directive Implementation

Use GraphQLs @deprecated directive to mark fields.

from typing import Dict, Optional

class DeprecationDirective:
    def __init__(self):
        self.directives: Dict[str, str] = {}

    def mark_deprecated(self, field: str,
                        reason: str,
                        replacement: Optional[str] = None):
        full_reason = reason
        if replacement:
            full_reason += f" Use {replacement} instead."
        self.directives[field] = full_reason

    def build_sdl_directive(self, field: str) -> str:
        reason = self.directives.get(field)
        if reason:
            return f' @deprecated(reason: "{reason}")'
        return ""

    def get_deprecation_reason(self, field: str
                               ) -> Optional[str]:
        return self.directives.get(field)

    def should_show_in_introspection(self, field: str
                                      ) -> bool:
        return True

directives = DeprecationDirective()
directives.mark_deprecated(
    "status", "Field is deprecated",
    "state"
)
sdl_directive = directives.build_sdl_directive("status")
print(f"SDL: {sdl_directive}")

Common Mistakes

Mistake 1: Removing Fields Immediately

Mark fields as deprecated first. Give clients at least 6 months before removing.

Mistake 2: Making Non-Nullable Fields Required

Adding a non-nullable field to an existing type breaks all queries that do not request it.

Mistake 3: Ignoring Introspection Deprecation

GraphQL introspection shows @deprecated. Rely on clients to check this instead of versioning.

Mistake 4: Versioning in the Endpoint

GraphQL does not need /v1/graphql and /v2/graphql. One endpoint evolves over time.

Mistake 5: Not Monitoring Deprecated Field Usage

Without usage tracking, you do not know when it is safe to remove deprecated fields.

Practice Questions

  1. Why does GraphQL not need traditional versioning?
  2. How does the @deprecated directive work in GraphQL?
  3. What is the safest way to add a new field to a GraphQL type?
  4. How do you handle field removal in GraphQL?
  5. What is the role of nullable fields in schema evolution?

Challenge

Build a GraphQL schema evolution system that tracks field additions and deprecations, generates SDL with @deprecated directives, checks client queries for deprecated field usage, and reports migration completion stats.

FAQ

Does GraphQL require versioning?

GraphQL typically uses a single evolving schema instead of versioned endpoints. Deprecation directives and nullable fields handle evolution without breaking clients.

How do I deprecate a GraphQL field?

Use the @deprecated directive with a reason and replacement field name. GraphQL introspection surfaces this to developer tools.

Can I remove a deprecated field?

Yes, but monitor usage first. Remove the field only when no active queries reference it.

What happens if I break a GraphQL schema?

Affected queries return errors. GraphQLs type system catches many issues at validation time, but runtime-breaking changes still require client updates.

How do I version a GraphQL API for major changes?

For breaking changes that cannot be handled by deprecation, create a new field or type alongside the old one. Avoid separate GraphQL endpoints.

Mini Project

Build a GraphQL schema evolution system that manages field lifecycle (add, deprecate, remove), generates SDL with @deprecated directives, checks client queries against the schema for compatibility, provides migration suggestions for deprecated fields, and tracks which fields are actively used.

What's Next

Learn about Versioning gRPC APIs for protobuf-based API Versioning, or explore Versioning REST APIs for REST API strategies.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro