Versioning in REST APIs — Complete Guide
In this tutorial, you'll learn about Versioning in REST. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
REST API ilink "API Versioning" >}} manages changes to endpoints and responses over time without breaking existing clients, using strategies like URI path versioning, header versioning, and query parameter versioning.
What You'll Learn
By the end of this lesson, you will implement REST URI versioning, understand backward compatibility rules, and know how to deprecate and sunset old versions.
Why It Matters
REST APIs evolve. Without versioning, a breaking change affects every client simultaneously. Versioning gives clients control over when they upgrade.
Real-World Use
Twitter API v1.1 and v2 coexist. The v2 API has different endpoints and response formats. Clients opt into v2 explicitly via the URL path.
REST Versioning Strategies
flowchart LR
URI[/v1/users] --> URIHandler
URI[/v2/users] --> V2Handler
Header["Accept: vnd.myapp.v2+json"] --> HeaderHandler
Query["?version=2"] --> QueryHandler
URI Path Versioning
# uri_versioning.py
from typing import Any, Callable, Dict
import re
class URIVersionRouter:
def __init__(self):
self.v1_handlers: Dict[str, Callable] = {}
self.v2_handlers: Dict[str, Callable] = {}
def v1(self, path: str):
def wrapper(fn):
self.v1_handlers[path] = fn
return fn
return wrapper
def v2(self, path: str):
def wrapper(fn):
self.v2_handlers[path] = fn
return fn
return wrapper
def route(self, request_path: str) -> Dict:
m = re.match(r"/v(\d+)(/.*)", request_path)
if not m:
return {"error": "No version in path"}
version = int(m.group(1))
path = m.group(2)
handlers = {1: self.v1_handlers, 2: self.v2_handlers}
handler = handlers.get(version, {}).get(path)
if not handler:
return {"error": f"v{version}{path} not found", "version": version}
return handler()
router = URIVersionRouter()
@router.v1("/users")
def v1_users():
return {"users": [{"id": 1, "name": "Alice"}], "version": "v1"}
@router.v2("/users")
def v2_users():
return {"data": [{"id": 1, "name": "Alice", "email": "a@x.com"}],
"meta": {"version": "v2"}}
print(router.route("/v1/users"))
print(router.route("/v2/users"))
print(router.route("/v3/users"))
Expected output:
{'users': [{'id': 1, 'name': 'Alice'}], 'version': 'v1'}
{'data': [{'id': 1, 'name': 'Alice', 'email': 'a@x.com'}], 'meta': {'version': 'v2'}}
{'error': 'v3/users not found', 'version': 3}
Backward Compatibility Check
# rest_compat.py
from typing import Dict, List, Tuple
class BackwardCompatibility:
def __init__(self, v1_schema: Dict, v2_schema: Dict):
self.v1 = v1_schema
self.v2 = v2_schema
def check_fields(self) -> List[str]:
issues = []
v1_fields = set(self.v1.get("fields", []))
v2_fields = set(self.v2.get("fields", []))
removed = v1_fields - v2_fields
for f in removed:
issues.append(f"BREAKING: field '{f}' removed in v2")
for f in v1_fields & v2_fields:
v1_type = self.v1["types"].get(f)
v2_type = self.v2["types"].get(f)
if v1_type and v2_type and v1_type != v2_type:
issues.append(f"BREAKING: field '{f}' type changed ({v1_type} -> {v2_type})")
return issues
v1 = {"fields": ["id", "name", "email"], "types": {"id": "int", "name": "str", "email": "str"}}
v2 = {"fields": ["id", "name", "email"], "types": {"id": "int", "name": "str", "email": "str"}}
checker = BackwardCompatibility(v1, v2)
issues = checker.check_fields()
print(f"Issues: {issues if issues else 'None (backward compatible)'}")
v2_breaking = {"fields": ["id", "name"], "types": {"id": "int", "name": "str"}}
checker2 = BackwardCompatibility(v1, v2_breaking)
issues2 = checker2.check_fields()
print(f"Issues: {issues2}")
Expected output:
Issues: None (backward compatible)
Issues: ['BREAKING: field email removed in v2']
Deprecation Header Middleware
# rest_deprecation.py
from datetime import datetime, timedelta
from typing import Dict, Optional
class RESTDeprecation:
def __init__(self):
self.deprecated: Dict[str, datetime] = {}
def deprecate(self, version: str, sunset_days: int = 180):
self.deprecated[version] = datetime.utcnow() + timedelta(days=sunset_days)
def headers(self, version: str) -> Dict[str, str]:
sunset = self.deprecated.get(version)
if not sunset:
return {}
return {
"Deprecation": datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S GMT"),
"Sunset": sunset.strftime("%a, %d %b %Y %H:%M:%S GMT"),
}
dep = RESTDeprecation()
dep.deprecate("v1", sunset_days=180)
response_headers = dep.headers("v1")
print(f"v1 headers: {response_headers}")
print(f"v2 headers: {dep.headers('v2') or 'No deprecation'}")
Expected output:
v1 headers: {'Deprecation': '...', 'Sunset': '...'}
v2 headers: No deprecation
Common Mistakes
1. No Versioning at All
Public API with no versioning forces all clients to accept breaking changes simultaneously.
2. Breaking Changes in Patch Versions
Changing response fields without a new version. Add fields, do not remove or rename without a version bump.
3. Supporting Too Many Versions
Maintaining 5+ versions indefinitely creates maintenance burden. Support n-1 or n-2 at most.
4. No Deprecation Warning
Removing a version without warning. At minimum, add deprecation headers 6 months before removal.
5. Inconsistent Version Format
Using v1, v2 in URIs but different scheme in headers. Be consistent across the API.
Practice Questions
1. What is URI path versioning?
Including the version in the URL path: /v1/users, /v2/users.
2. How long should you support an old REST API version?
At minimum 6 months. 12 months is standard for public APIs.
3. What is a breaking change in REST?
Removing a field, changing a field type, removing an endpoint, changing authentication.
4. How do you communicate deprecation?
Deprecation HTTP header, Sunset header, documentation notices, and Migration guide.
Challenge
Build a REST versioning middleware that routes requests to the correct handler based on path version, adds deprecation headers for old versions, and logs version usage.
FAQ
Mini Project: REST Version Manager
# rest_version_mgr.py
from typing import Any, Callable, Dict
class RESTVersionManager:
def __init__(self):
self.handlers: Dict[int, Dict[str, Callable]] = {}
def register(self, version: int, path: str, handler: Callable):
self.handlers.setdefault(version, {})[path] = handler
def call(self, version: int, path: str) -> Dict:
h = self.handlers.get(version, {}).get(path)
return h() if h else {"error": f"v{version}{path} not found"}
mgr = RESTVersionManager()
mgr.register(1, "/users", lambda: {"users": []})
mgr.register(2, "/users", lambda: {"data": [], "meta": {"v": 2}})
print(mgr.call(1, "/users"))
print(mgr.call(2, "/users"))
print(mgr.call(3, "/users"))
Expected output:
{'users': []}
{'data': [], 'meta': {'v': 2}}
{'error': 'v3/users not found'}
What's Next
You understand REST versioning. Next, explore GraphQL versioning, then tooling and ecosystem comparison.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro