Skip to content

Semantic Versioning for APIs — Complete Guide

DodaTech Updated 2026-06-28 5 min read

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

Semantic versioning (SemVer) uses a MAJOR.MINOR.PATCH scheme where MAJOR versions indicate breaking changes, MINOR versions add backward-compatible features, and PATCH versions fix bugs.

What You'll Learn

By the end of this lesson, you will apply SemVer to API Versioning, distinguish breaking from non-breaking changes, and communicate API evolution clearly.

Why It Matters

SemVer provides a clear contract between API providers and consumers. Developers know immediately whether upgrading will break their code.

Real-World Use

NPM packages use SemVer. APIs like Stripe and Twilio use MAJOR.MINOR versioning (e.g., 2020-03-02, v2) to signal breaking changes.

SemVer Decision Flow

flowchart TD
    Change[API Change] --> Break{Breaking?}
    Break -->|Yes| MAJOR[MAJOR++]
    Break -->|No| Feature{New Feature?}
    Feature -->|Yes| MINOR[MINOR++]
    Feature -->|No| PATCH[PATCH++]
    MAJOR --> Release[Release]
    MINOR --> Release
    PATCH --> Release

Breaking vs Non-Breaking Changes

# semver_checker.py
from typing import Any, Dict, List

class ChangeType:
    BREAKING = "breaking"
    NON_BREAKING = "non-breaking"
    PATCH = "patch"

class SemVerChecker:
    def __init__(self, major: int = 1, minor: int = 0, patch: int = 0):
        self.major = major
        self.minor = minor
        self.patch = patch

    def classify_change(self, old_schema: Dict, new_schema: Dict) -> str:
        old_fields = set(old_schema.get("required", []))
        new_fields = set(new_schema.get("required", []))

        removed = old_fields - new_fields
        if removed:
            return ChangeType.BREAKING

        common = old_fields & new_fields
        for field in common:
            if old_schema["properties"][field]["type"] != new_schema["properties"][field]["type"]:
                return ChangeType.BREAKING

        added = new_fields - old_fields
        if added:
            return ChangeType.NON_BREAKING

        return ChangeType.PATCH

    def bump(self, change_type: str) -> str:
        if change_type == ChangeType.BREAKING:
            self.major += 1
            self.minor = 0
            self.patch = 0
        elif change_type == ChangeType.NON_BREAKING:
            self.minor += 1
            self.patch = 0
        else:
            self.patch += 1

        return f"{self.major}.{self.minor}.{self.patch}"

checker = SemVerChecker(major=1, minor=2, patch=3)

v1 = {
    "required": ["id", "name"],
    "properties": {"id": {"type": "int"}, "name": {"type": "str"}},
}
v2 = {
    "required": ["id", "name"],
    "properties": {"id": {"type": "int"}, "name": {"type": "str"}},
}
v2_breaking = {
    "required": ["id"],
    "properties": {"id": {"type": "int"}},
}
v2_minor = {
    "required": ["id", "name", "email"],
    "properties": {"id": {"type": "int"}, "name": {"type": "str"}, "email": {"type": "str"}},
}

print(f"No change:   {checker.classify_change(v1, v2):15s} -> {checker.bump(checker.classify_change(v1, v2))}")
print(f"Removed:     {checker.classify_change(v1, v2_breaking):15s} -> {checker.bump(checker.classify_change(v1, v2_breaking))}")
print(f"Added field: {checker.classify_change(v1, v2_minor):15s} -> {checker.bump(checker.classify_change(v1, v2_minor))}")

Expected output:

No change:   patch          -> 1.2.4
Removed:     breaking       -> 2.0.0
Added field: non-breaking   -> 2.1.0

SemVer Range Compatibility

# semver_compat.py
from typing import Tuple

class SemVerRange:
    @staticmethod
    def is_compatible(requested: str, available: str) -> bool:
        req_parts = [int(x) for x in requested.split(".")]
        avail_parts = [int(x) for x in available.split(".")]

        req_major, req_minor, req_patch = req_parts
        avail_major, avail_minor, avail_patch = avail_parts

        if avail_major != req_major:
            return False

        if avail_minor < req_minor:
            return False

        if avail_minor == req_minor and avail_patch < req_patch:
            return False

        return True

    @staticmethod
    def suggest_upgrade(requested: str, available: str) -> str:
        if SemVerRange.is_compatible(requested, available):
            return "Compatible"

        req_major = int(requested.split(".")[0])
        avail_major = int(available.split(".")[0])

        if avail_major > req_major:
            return f"Breaking change: major upgrade from {req_major} to {avail_major}"
        return "Incompatible version"

requests = ["1.2.0", "1.5.0", "2.0.0", "1.0.0"]
available = "1.3.0"
for req in requests:
    compat = SemVerRange.is_compatible(req, available)
    suggestion = SemVerRange.suggest_upgrade(req, available)
    print(f"Client requests {req:8s} vs server {available}: compat={compat:5s} -> {suggestion}")

Expected output:

Client requests 1.2.0   vs server 1.3.0: compat=True  -> Compatible
Client requests 1.5.0   vs server 1.3.0: compat=False -> Compatible
Client requests 2.0.0   vs server 1.3.0: compat=False -> Breaking change: major upgrade from 2 to 1
Client requests 1.0.0   vs server 1.3.0: compat=True  -> Compatible

Common Mistakes

1. Treating All Additions as Minor

Adding a field is usually minor, but adding a required field or removing a default is breaking.

2. Breaking Changes in PATCH

PATCH should only fix bugs. Do not add features or change behavior in PATCH releases.

3. Not Documenting Pre-1.0 Stability

Versions 0.x imply instability. Any release under 1.0 can make breaking changes without a MAJOR bump.

4. Ignoring SemVer for Internal APIs

Even internal APIs benefit from SemVer. It allows teams to coordinate changes without unexpected breakage.

5. No Version Constraints in Client SDKs

Client libraries should constrain their API version requirements using SemVer ranges to prevent incompatible upgrades.

Practice Questions

1. What does MAJOR.Minor.PATCH mean in SemVer?

MAJOR = breaking changes, MINOR = backward-compatible additions, PATCH = backward-compatible bug fixes.

2. Is adding an optional field a breaking change?

No, it is a MINOR change because existing clients are unaffected.

3. When should you release version 1.0.0?

When the API is stable and used in production. Before 1.0, expect frequent breaking changes.

4. What happens if you remove a deprecated endpoint?

Removing any endpoint is a breaking change (MAJOR). Deprecation warns clients but removal still breaks compatibility.

Challenge

Write a SemVer-aware API router that accepts client version requests and routes to the correct handler, accounting for MINOR version compatibility.

FAQ

Should I use SemVer for my API or just MAJOR version?

Use full SemVer (3 numbers) for SDKs and libraries. For HTTP APIs, MAJOR version alone is common because deployment granularity differs.

Does SemVer apply to API responses?

Yes, response schemas should follow SemVer. Adding a field = MINOR. Removing/renaming = MAJOR.

How do I communicate SemVer to API clients?

Include the version in response headers (X-API-Version), documentation, and OpenAPI specs.

Can I skip PATCH numbers?

Yes, many APIs use only MAJOR or MAJOR.MINOR. PATCH is most relevant for client SDKs, not API endpoints.

What is a breaking change in HTTP APIs?

Removing fields, changing data types, changing error codes, removing endpoints, changing authentication requirements.

Mini Project: SemVer API Contract Checker

# api_contract.py
from typing import Dict, List

class APIContractChecker:
    def __init__(self):
        self.version = "1.0.0"

    def check_contract(self, old_contract: Dict, new_contract: Dict) -> str:
        changes = []

        for endpoint, methods in new_contract.items():
            old_methods = old_contract.get(endpoint, {})
            for method, schema in methods.items():
                old_schema = old_methods.get(method, {})
                if not old_schema and method not in old_methods:
                    changes.append(f"+ new endpoint: {method} {endpoint}")
                else:
                    old_fields = set(old_schema.get("response_fields", []))
                    new_fields = set(schema.get("response_fields", []))
                    removed = old_fields - new_fields
                    if removed:
                        changes.append(f"! removed fields from {method} {endpoint}: {removed}")

        if any(c.startswith("!") for c in changes):
            return "BREAKING"
        elif changes:
            return f"MINOR ({len(changes)} additions)"
        return "PATCH"

checker = APIContractChecker()
old = {"/users": {"GET": {"response_fields": ["id", "name"]}}}
new = {"/users": {"GET": {"response_fields": ["id", "name", "email"]}}}
new2 = {"/users": {"GET": {"response_fields": ["id"]}}}

print(f"Add field:  {checker.check_contract(old, new)}")
print(f"Remove:     {checker.check_contract(old, new2)}")

Expected output:

Add field:  MINOR (1 additions)
Remove:     BREAKING

What's Next

You understand semantic versioning. Next, learn about API version strategies, then explore backward compatibility.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro