Skip to content

Backward Compatibility in API Versioning

DodaTech Updated 2026-06-28 6 min read

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

Backward compatibility means a new API version does not break existing clients. Old requests still work, old responses still parse correctly, and old behavior is preserved.

What You'll Learn

By the end of this lesson, you will design APIs that evolve without breaking existing clients using additive changes, tolerance, and careful field management.

Why It Matters

Backward compatible APIs let clients upgrade at their own pace. Breaking changes force urgent updates and erode developer trust in your platform.

Real-World Use

Stripe adds fields to responses but never removes or renames them. They use a version-specific API (2020-03-02, 2020-08-27) while maintaining backward compatibility within each version.

Backward Compatibility Flow

flowchart LR
    Change[Proposed Change] --> Check{Backward Compatible?}
    Check -->|Yes| Safe[Additive Change]
    Check -->|No| Mitigate{Can Mitigate?}
    Mitigate -->|Yes| Adapt[Add Field, Keep Old]
    Mitigate -->|No| NewVer[New Major Version]

Additive Field Changes

# additive_changes.py
from typing import Any, Dict, Optional

class BackwardCompatibleResponse:
    def __init__(self, version: str):
        self.version = version
        self.fields = {"id": int, "name": str}
        self.optional_fields: Dict[str, type] = {}

    def add_optional_field(self, name: str, field_type: type):
        self.optional_fields[name] = field_type

    def build_response(self, data: Dict) -> Dict:
        response = {}
        for field, field_type in self.fields.items():
            if field in data:
                response[field] = data.get(field)

        for field, field_type in self.optional_fields.items():
            if field in data:
                response[field] = data.get(field)

        response["_version"] = self.version
        return response

    def parse_response(self, raw: Dict) -> Dict:
        parsed = {}
        for field, field_type in self.fields.items():
            if field not in raw:
                return {"error": f"Missing required field: {field}"}
            parsed[field] = raw[field]

        for field in self.optional_fields:
            if field in raw:
                parsed[field] = raw[field]

        return parsed

v1 = BackwardCompatibleResponse("1.0.0")
v2 = BackwardCompatibleResponse("1.1.0")
v2.add_optional_field("email", str)

data = {"id": 1, "name": "Alice", "email": "alice@example.com"}
response_v1 = v1.build_response(data)
response_v2 = v2.build_response(data)

print(f"v1 response: {response_v1}")
print(f"v2 response: {response_v2}")
print(f"v1 parses v2 response: {v1.parse_response(response_v2)}")
print(f"v1 parses v1 response: {v1.parse_response(response_v1)}")

Expected output:

v1 response: {'id': 1, 'name': 'Alice', '_version': '1.0.0'}
v2 response: {'id': 1, 'name': 'Alice', 'email': 'alice@example.com', '_version': '1.1.0'}
v1 parses v2 response: {'id': 1, 'name': 'Alice'}
v1 parses v1 response: {'id': 1, 'name': 'Alice'}

Type Widening

# type_widening.py
from typing import Any, Dict, List, Union

class TypeWidener:
    @staticmethod
    def widen(value: Any) -> Any:
        if isinstance(value, int):
            return float(value)
        if isinstance(value, list):
            return {"items": value, "count": len(value)}
        if isinstance(value, str):
            return value
        return value

    @staticmethod
    def could_parse(old_value: Any, new_value: Any) -> bool:
        if isinstance(old_value, int) and isinstance(new_value, (int, float)):
            return True
        if isinstance(old_value, str) and isinstance(new_value, str):
            return True
        if isinstance(old_value, list) and isinstance(new_value, dict):
            return "items" in new_value
        return False

widener = TypeWidener()
tests = [
    (1, 1.0),
    ([1, 2, 3], {"items": [1, 2, 3], "count": 3}),
    ("hello", "hello"),
    (1, "string"),
]
for old_v, new_v in tests:
    parsable = widener.could_parse(old_v, new_v)
    print(f"old={old_v!r:12s} new={new_v!r:30s} parsable={parsable}")

Expected output:

old=1            new=1.0                           parsable=True
old=[1, 2, 3]    new={'items': [1, 2, 3], 'count': 3} parsable=True
old='hello'      new='hello'                       parsable=True
old=1            new='string'                      parsable=False

Default Value Migration

# default_migration.py
from typing import Any, Dict, Optional

class DefaultMigration:
    def __init__(self, version: str):
        self.version = version
        self.defaults = {"status": "active"}

    def add_default(self, field: str, value: Any):
        self.defaults[field] = value

    def ensure_defaults(self, data: Dict) -> Dict:
        result = dict(data)
        for field, default in self.defaults.items():
            if field not in result:
                result[field] = default
        result["_v"] = self.version
        return result

    def remove_field_with_default(self, data: Dict, field: str, default: Any) -> Dict:
        result = dict(data)
        result.pop(field, None)
        return result

v1 = DefaultMigration("1.0")
v2 = DefaultMigration("1.1")
v2.add_default("timezone", "UTC")

old_response = {"id": 1, "name": "Alice"}
new_response = v2.ensure_defaults(old_response)

print(f"v1 response: {v1.ensure_defaults(old_response)}")
print(f"v2 response (with default): {new_response}")
print(f"v1 can parse: {v1.ensure_defaults(new_response)}")

Expected output:

v1 response: {'id': 1, 'name': 'Alice', 'status': 'active', '_v': '1.0'}
v2 response (with default): {'id': 1, 'name': 'Alice', 'status': 'active', 'timezone': 'UTC', '_v': '1.1'}
v1 can parse: {'id': 1, 'name': 'Alice', 'status': 'active', 'timezone': 'UTC', '_v': '1.0'}

Common Mistakes

1. Removing Fields

Once a field is in the response, it must stay (or be replaced by a default). Removing fields breaks JSON deserialization.

2. Changing Data Types

Changing a field from int to string or null to object breaks type-specific client code.

3. Adding Required Fields

New fields must be optional for at least one major version cycle. Adding required fields breaks POST/PUT payloads.

4. Changing Error Semantics

Changing error codes or response body structure for existing errors breaks error handling code on clients.

5. Reducing Input Tolerance

If v1 accepts either string or number for a field, v2 must also accept both. Narrowing input types breaks existing callers.

Practice Questions

1. What is an additive change in APIs?

Adding a new optional field, new endpoint, or new HTTP method without modifying existing behavior.

2. Why must new fields be optional?

Existing clients do not send the new field. Making it required would reject otherwise valid requests.

3. What is type widening?

Replacing a type with a more general type, like changing int to float or a specific enum to a string.

4. When should you create a new MAJOR version?

When a change is inherently breaking: removing fields, changing types, reworking authentication.

Challenge

Implement a backward compatibility checker that compares two API specification versions and reports all breaking changes with migration suggestions.

FAQ

Can I rename a field in a backward-compatible way?

Add the new field name alongside the old one. Keep both for one version cycle, then deprecate and remove the old one.

How do I handle defaults for new required fields?

Introduce the field as optional first, log warnings for clients that do not include it, then make it required in the next major version.

Is changing error messages backward compatible?

Changing existing error message text is technically breaking if clients parse error messages programmatically. Change slowly.

What is the tolerance principle?

Servers should be liberal in what they accept (tolerate extra fields, flexible types) but conservative in what they send.

How do I test backward compatibility?

Run contract tests comparing old and new versions. Use snapshot testing and schema diffing tools.

Mini Project: Compatibility Checker

# compat_checker.py
from typing import Any, Dict, List, Tuple

class CompatChecker:
    def __init__(self):
        self.issues: List[str] = []

    def compare_schemas(self, old: Dict, new: Dict) -> List[str]:
        self.issues = []

        old_fields = old.get("fields", {})
        new_fields = new.get("fields", {})

        for field, props in old_fields.items():
            if field not in new_fields:
                self.issues.append(f"BREAKING: field '{field}' removed")
            elif props.get("type") != new_fields[field].get("type"):
                self.issues.append(
                    f"BREAKING: field '{field}' type changed: {props['type']} -> {new_fields[field]['type']}")
            elif new_fields[field].get("required") and not props.get("required"):
                self.issues.append(f"BREAKING: field '{field}' became required")

        for field, props in new_fields.items():
            if field not in old_fields and props.get("required"):
                self.issues.append(f"BREAKING: new required field '{field}' added")

        return self.issues

checker = CompatChecker()
old = {"fields": {"id": {"type": "int", "required": True}, "name": {"type": "str"}}}
new = {"fields": {"id": {"type": "str", "required": True}, "email": {"type": "str", "required": True}}}

for issue in checker.compare_schemas(old, new):
    print(issue)

Expected output:

BREAKING: field 'name' removed
BREAKING: field 'id' type changed: int -> str
BREAKING: new required field 'email' added

What's Next

You understand backward compatibility. Next, learn about API deprecation, then explore sunset headers.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro