DRF Versioning

5 interview angles 4 min read source

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 (intstring).
  • 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:

  1. Ship v2 alongside v1.
  2. Add Deprecation: true and Sunset: <date> headers to v1 responses (RFC 8594/9745). Document the sunset date.
  3. Log every v1 request with the client identifier — track who hasn’t migrated.
  4. 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 of if version == "v2" is a refactor waiting to happen — split into separate viewsets.
  • AcceptHeaderVersioning and HTTP caches. Caches keyed on URL won’t distinguish versions; you need Vary: Accept. URL versioning avoids this.
  • HostNameVersioning and dev environments. Requires DNS / /etc/hosts setup. Annoying for local dev — most teams skip it.
  • No ALLOWED_VERSIONS means 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.version come from?” — populated by the configured versioning_class based 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 Deprecation and Sunset headers, log v1 traffic, communicate the sunset date, remove after the long tail goes quiet.