DRF Versioning
Versioning lets you evolve an API without breaking existing clients. DRF provides the wiring (where the version comes from, how it’s exposed to your code), but how you actually serve different responses for different versions is on you.
The five built-in versioning schemes
| Class | Where the version comes from | Example request |
|---|---|---|
URLPathVersioning |
URL prefix | GET /api/v2/books/ |
NamespaceVersioning |
URL namespace | path("v2/", include("v2.urls", namespace="v2")) |
AcceptHeaderVersioning |
Accept header parameter |
Accept: application/json; version=2 |
QueryParameterVersioning |
query string | GET /api/books/?version=2 |
HostNameVersioning |
subdomain | Host: v2.api.example.com |
URLPathVersioning is the most common in practice — explicit, cacheable, easy for ops to monitor and route.
Setup
# settings.py
REST_FRAMEWORK = {
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
"DEFAULT_VERSION": "v1",
"ALLOWED_VERSIONS": ["v1", "v2"],
"VERSION_PARAM": "version",
}
# urls.py
urlpatterns = [
path("api/<str:version>/", include(router.urls)),
]
request.version is now available in views as "v1" or "v2". An unknown version (not in ALLOWED_VERSIONS) → 404.
Using the version in a view
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
def get_serializer_class(self):
if self.request.version == "v2":
return BookV2Serializer
return BookV1Serializer
Branch on request.version for whatever varies: serializer, queryset, response shape, behavior.
Strategies for actually maintaining multiple versions
Strategy 1: Branch by version inside the same view
def get_serializer_class(self):
return {"v1": BookV1Serializer, "v2": BookV2Serializer}[self.request.version]
Simple, fewer files. Bad when v1 and v2 diverge a lot — the view becomes a pile of branches.
Strategy 2: Separate URL files per version
# v1/urls.py — registers BookV1ViewSet
# v2/urls.py — registers BookV2ViewSet
urlpatterns = [
path("api/v1/", include("v1.urls")),
path("api/v2/", include("v2.urls")),
]
Clean separation. Costs duplication (any change in v1 won’t propagate to v2 unless intentional). Good for “v1 frozen, v2 in active development.”
Strategy 3: Inheritance — v2 inherits from v1, overrides what changed
class BookViewSet(viewsets.ModelViewSet):
serializer_class = BookV1Serializer
queryset = Book.objects.all()
class BookV2ViewSet(BookViewSet):
serializer_class = BookV2Serializer
Reuses logic, isolates changes. Best when v2 ≈ v1 + tweaks.
When to bump a version (vs add a field)
Backwards-compatible changes — no version bump required:
- Adding a new optional response field.
- Adding a new endpoint.
- Adding a new optional request param.
- Loosening a validation rule (accepting more input).
Breaking changes — version bump or new endpoint:
- Removing or renaming a field.
- Changing a field’s type (
int→string). - Tightening validation (rejecting input that used to work).
- Changing default behavior or response shape.
- Changing status codes.
In practice, most teams version rarely and lean on additive changes. Hyrum’s Law applies — clients depend on observable behavior whether you intended it or not.
Per-view versioning class override
class WebhookView(APIView):
versioning_class = None # webhook URL is /webhooks/stripe/, no version
Version-aware reverse URLs
HyperlinkedModelSerializer and Hyperlinked*Field need to know the version to build URLs. DRF’s URL resolver auto-adds the version kwarg if URLPathVersioning is in use. With NamespaceVersioning, reverse URLs resolve from the same namespace as the request.
Schemas per version
drf-spectacular auto-generates one schema per DEFAULT_VERSION by default. To produce one schema per version, configure SPECTACULAR_SETTINGS["VERSION"] and serve schema URLs per version:
path("api/v1/schema/", SpectacularAPIView.as_view(api_version="v1")),
path("api/v2/schema/", SpectacularAPIView.as_view(api_version="v2")),
See 16_schema_openapi.md.
Deprecation flow
A typical lifecycle:
- Ship v2 alongside v1.
- Add
Deprecation: trueandSunset: <date>headers to v1 responses (RFC 8594/9745). Document the sunset date. - Log every v1 request with the client identifier — track who hasn’t migrated.
- Remove v1 after the sunset date and the long tail of clients has gone quiet.
class DeprecatedV1Middleware:
def __init__(self, get_response): self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if "/api/v1/" in request.path:
response["Deprecation"] = "true"
response["Sunset"] = "Mon, 01 Jan 2027 00:00:00 GMT"
return response
Pitfalls
- Versioning without a deprecation plan. If you never remove v1, you’re maintaining N forever. Set a sunset date when you ship vN+1.
- Branching everywhere on
request.version. A view full ofif version == "v2"is a refactor waiting to happen — split into separate viewsets. AcceptHeaderVersioningand HTTP caches. Caches keyed on URL won’t distinguish versions; you needVary: Accept. URL versioning avoids this.HostNameVersioningand dev environments. Requires DNS //etc/hostssetup. Annoying for local dev — most teams skip it.- No
ALLOWED_VERSIONSmeans any string is accepted — typo’d version slips through.
Interview angle
- “What versioning schemes does DRF support and which is most common?” — URL path, namespace, accept header, query param, hostname. URL path is most common (explicit, cacheable, ops-friendly).
- “Where does
request.versioncome from?” — populated by the configuredversioning_classbased on URL kwarg / header / query / hostname. - “How do you actually maintain two versions of a serializer?” — branch in
get_serializer_class(), or split into separate viewsets per version (cleaner when divergence is large), or v2 inherits v1 and overrides what changed. - “What changes require a version bump?” — anything breaking: removed/renamed fields, changed types, tightened validation, changed defaults. Adding a new optional field doesn’t.
- “What’s a deprecation strategy for an old version?” — add
DeprecationandSunsetheaders, log v1 traffic, communicate the sunset date, remove after the long tail goes quiet.