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?
answer
- patterns now need a version kwarg
- Django reverse knows nothing of it
- rest_framework.reverse takes request
- serializer context needs the request
basics
~10 sVersioned 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 sWith `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 linesfrom 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
Know that DRF has its own reverse() that takes the request, and that serializers need the request in context for hyperlinks.
Explain how each scheme's reverse() rewrites URLs and why get_serializer() supplies the request while a hand-built serializer does not.
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.
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