Semantic Versioning for APIs — Complete Guide
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
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