skip to content

After moving a DRF API to URLPathVersioning with /v1/ and /v2/ prefixes, why do reverse() calls and hyperlinked serializers break, and how do you keep links on the caller's version?

level: seniorimportance: nice to knowfreq 24%

answer

  1. patterns now need a version kwarg
  2. Django reverse knows nothing of it
  3. rest_framework.reverse takes request
  4. serializer context needs the request

basics

~10 s

Versioned patterns need a version argument that django.urls.reverse() is not given, so it raises NoReverseMatch. DRF's reverse(..., request=request) asks request.versioning_scheme to add it; hyperlinked fields do this when the serializer context holds the request.

solid answer

~30 s

With `URLPathVersioning` every pattern captures `version`, so a plain `django.urls.reverse('job-detail', kwargs={'pk': 1})` raises `NoReverseMatch`, and hard-coding `version='v1'` sends v2 clients back to v1. DRF's `rest_framework.reverse.reverse(viewname, ..., request=request)` delegates to `request.versioning_scheme.reverse()`: `URLPathVersioning` injects the caller's `version` kwarg, `NamespaceVersioning` prefixes the view name with `v2:`, and `QueryParameterVersioning` adds `?version=`. Hyperlinked fields call that function with the request from the serializer context; generic views supply it through `get_serializer_context()`, but a serializer built by hand needs `context={'request': request}` or the field fails its request assertion. Code with no request, such as a task sending emails, must pass the version explicitly.

code

python · 18 lines
python
from rest_framework import status, viewsets
from rest_framework.response import Response
from rest_framework.reverse import reverse

from jobs.models import Job
from jobs.serializers import JobSerializer


class JobViewSet(viewsets.ModelViewSet):
    queryset = Job.objects.all()
    serializer_class = JobSerializer

    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        job = serializer.save()
        location = reverse("job-detail", kwargs={"pk": job.pk}, request=request)
        return Response(serializer.data, status=status.HTTP_201_CREATED, headers={"Location": location})

go deeper

for a junior

Know that DRF has its own reverse() that takes the request, and that serializers need the request in context for hyperlinks.

for a middle

Explain how each scheme's reverse() rewrites URLs and why get_serializer() supplies the request while a hand-built serializer does not.

for a senior

Plan a versioning migration that keeps every link on the caller's version, covers request-less code paths and is enforced by per-version tests.

for a principal

Weigh URL-based schemes, which spread version-aware linking through the codebase, against header-based ones that keep URLs stable.

## The symptom A jobs API moves from unversioned URLs to `/v1/jobs/` and `/v2/jobs/` using Django REST Framework's `URLPathVersioning`. Soon after: - views that build a `Location` header or a link with `django.urls.reverse()` raise `NoReverseMatch`; - someone fixes that with `kwargs={'version': 'v1', ...}`, and v2 responses now link to v1 resources; - a serializer instantiated in a custom action fails with an assertion that the hyperlinked field `requires the request in the serializer context`; - a handler written as `def get(self, request, pk)` fails with an unexpected `version` argument. ## Why plain Django reverse fails With `URLPathVersioning` the URLconf captures the version, e.g. `re_path(r'^(?P<version>(v1|v2))/', include(router.urls))`. Every route under it now **requires** a `version` kwarg to reverse. Django's `reverse()` knows nothing about the request, so it cannot supply one; without it the pattern does not match. With `NamespaceVersioning`, the names live under `v1:` and `v2:` namespaces instead, and an un-namespaced name fails the same way. ## How DRF's reverse fixes it `rest_framework.reverse.reverse(viewname, args=None, kwargs=None, request=None, format=None)` checks `request.versioning_scheme`, which `APIView.initial()` set when it determined the version: | Scheme | What its `reverse()` does | |---|---| | `URLPathVersioning` | adds `version=request.version` to the kwargs | | `NamespaceVersioning` | prefixes the view name, e.g. `v2:job-detail` | | `QueryParameterVersioning` | appends `?version=v2` to the URL | | `AcceptHeaderVersioning` | nothing; the URL carries no version | If the scheme's reversal raises `NoReverseMatch`, DRF retries plain reversal. Passing `request` also makes the URL absolute and preserves a `?format=` override from the incoming request. ## Hyperlinked serializers `HyperlinkedModelSerializer`, `HyperlinkedIdentityField` and `HyperlinkedRelatedField` call DRF's `reverse()` with the request found in `serializer.context`: 1. generic views and viewsets pass `{'request', 'format', 'view'}` through `get_serializer_context()`, so `self.get_serializer(...)` just works; 2. a serializer created directly, `JobSerializer(job)`, has no request, and the field raises an assertion telling you to add `context={'request': request}`; 3. with the request present, links follow the caller's version automatically. ## Where no request exists Background jobs, management commands and email templates have no DRF request, so nothing can infer a version. Decide explicitly: pass the version (or a full base URL) into the job, and call `reverse('v2:job-detail', ...)` or supply `kwargs={'version': 'v2'}`. Hard-coding the latest version there is fine; hard-coding it in a request path is the bug. ## A checklist for the migration - Replace `django.urls.reverse` with `rest_framework.reverse.reverse(..., request=request)` in views and serializers. - Build serializers through `self.get_serializer()` or pass the request in `context`. - Accept `**kwargs` in handler signatures when using `URLPathVersioning`, because the `version` kwarg is passed to handlers. - Set `ALLOWED_VERSIONS` so an unknown prefix is a 404 rather than a silently accepted version. - Add a test per version asserting that every link in a v2 response starts with `/v2/`.

  • Does AcceptHeaderVersioning need version-aware reversing?
    No. The version travels in the Accept header, not the URL, so the same URL serves every version. `AcceptHeaderVersioning` does not override `reverse()`, and links in a response are identical for v1 and v2 callers; the client keeps sending its Accept header.
  • A background task emails a job link after the request has finished. How does it get the right version?
    It cannot read `request.versioning_scheme`, because there is no DRF request in the task. Pass the version from the view into the task arguments, then reverse with an explicit namespace such as `v2:job-detail` or with `kwargs={'version': 'v2'}`, plus a configured base URL for the host.

saying these in an interview costs you the question

  • django.urls.reverse() reads the version from the current request automatically
  • Hard-coding version='v1' in reverse kwargs is a safe fix
  • Hyperlinked fields work without the request in the serializer context
  • NamespaceVersioning needs a version kwarg in every reverse call