~/TechPurAI
~/tutorials/django-rest-framework/api-versioning
intermediate·part 20 of 22·3 min read

API versioning: URL and header-based strategies

Updated Aug 16, 2026Python · Django

Every part of this series has changed TaskSerializer's shape freely — adding owner as a nested object in part 12 changed what every existing client received back. That's fine during development; it's a breaking change against anyone already depending on the old shape once an API is actually live. Versioning is how a breaking change ships without breaking every existing client at once.

URL path versioning

python
# taskapi/settings.py
REST_FRAMEWORK = {
    # ... existing settings ...
    "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
    "ALLOWED_VERSIONS": ["v1", "v2"],
    "DEFAULT_VERSION": "v1",
}
python
# taskapi/urls.py
urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/<str:version>/", include("tasks.urls")),
]

URLPathVersioning makes the version an explicit part of the URL — /api/v1/tasks/ versus /api/v2/tasks/ — the most visible and cache-friendly style, since two different URLs naturally cache and log as two distinct things.

Reading the version inside a view

python
class TaskSerializer(serializers.ModelSerializer):
    def to_representation(self, instance):
        data = super().to_representation(instance)
        if self.context["request"].version == "v1":
            data["owner"] = instance.owner_id   # v1: bare ID, the original shape
        return data

    class Meta:
        model = Task
        fields = ["id", "title", "description", "completed", "due_date", "owner", "created_at"]

self.context["request"] is available inside any serializer used from a DRF view (the generic views and viewsets used throughout this series pass it in automatically), and .version reflects whichever version the URL specified. Overriding to_representation() lets a v1 request keep receiving the original bare-ID owner (matching how this series had it before part 12's nested serializer change), while v2 gets the newer nested-object shape by default — one serializer, branching only where the shape actually differs.

Header-based versioning: the alternative

python
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.AcceptHeaderVersioning",
bash
curl http://127.0.0.1:8000/api/tasks/ -H "Accept: application/json; version=2.0"

AcceptHeaderVersioning keeps one URL for every version, distinguishing them by the Accept header instead — arguably more "correct" REST design, since a resource's URL is meant to identify a thing, not a specific representation of it. In practice it's less visible (harder to spot which version a request is targeting from logs alone) and harder to test by simply pasting a URL into a browser — URLPathVersioning tends to win in real projects for that discoverability, even though it's the less "pure" option.

Why this matters before it's needed

Retrofitting versioning onto an API that's already live and already has real clients is far more disruptive than building it in from the start, even before there's a second version to ship — DEFAULT_VERSION = "v1" above costs nothing today and means a genuine v2 later is additive, not a breaking migration for every existing client.

Common mistake

Treating versioning as something to add "once it's actually needed." By the time a breaking change is unavoidable, every existing client is already depending on the current shape with no version marker at all — there's no way to introduce v1 retroactively without it also being a breaking change for whoever's already calling the unversioned URL.

Next: documenting all of this — every endpoint, every serializer field, every status code — generated from the code itself instead of a separate document to maintain by hand.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous19. Testing a DRF API with APITestCase and APIClientnext →21. Documenting the API with drf-spectacular and OpenAPI