Django API Versioning
Django API Versioning is a Python package for API versioning in Django and Django REST Framework (DRF) projects. Declare an endpoint once with @endpoint, and the registry registers every versioned URL for you — no manual path() in urls.py, no if request.version in views.
"I need to change the response. v1 clients must not break."
You shipped v1 with a string amount. Now the team needs integer cents and an explicit currency code. Changing the view breaks every v1 client in production.
Two contracts, two decorators, zero changes to urls.py:
# apps/orders/views.py
from django.http import JsonResponse
from django_api_versioning.decorators import endpoint
@endpoint("orders/", version=1)
def order_list_v1(request):
return JsonResponse({"total": "19.99"})
@endpoint("orders/", version=2)
def order_list_v2(request):
return JsonResponse({"total": 1999, "currency": "USD"})
That is the entire change. Nothing goes in urls.py. The package's URLconf — which you include once during setup — picks both routes up automatically.
GET /api/v1/orders/ → {"total": "19.99"}
GET /api/v2/orders/ → {"total": 1999, "currency": "USD"}
GET /api/v3/orders/ → {"total": 1999, "currency": "USD"}
GET /api/v4/orders/ → {"total": 1999, "currency": "USD"}
GET /api/v5/orders/ → {"total": 1999, "currency": "USD"}
Why do v3, v4, v5 return the v2 body? Because version=2 declares a segment: the view serves v2 through API_MAX_VERSION, until you add another @endpoint for the same route. When v4 needs its own contract, add @endpoint("orders/", version=4); until then, unchanged endpoints inherit automatically.
The function name order_list_v2 is a convention for readers. The version range is defined by version=2, not by the name.
- Package:
django-api-versioning - Source code: mojtaba-arvin/django-api-versioning
- License: MIT
Why not just use DRF's built-in versioning?
DRF ships with URLPathVersioning, NamespaceVersioning, AcceptHeaderVersioning, QueryParameterVersioning, and HostNameVersioning. Every one of them does the same thing: extract the version from the request and set request.version. None of them decide which view serves which version, and none of them register your URLs. DRF documents this explicitly: "How you vary the API behavior is up to you."
| Feature | DRF URLPathVersioning |
django-api-versioning |
|---|---|---|
| Extract version from request | ✅ | ✅ (reads request.version) |
| Register URL routes automatically | ❌ — you write path() per version | ✅ — decorator registers |
| Different view per version | ❌ — you write if request.version | ✅ — @endpoint decorator |
| Segment inheritance (version=2 serves v3+) | ❌ | ✅ — default behavior |
| Explicit version sets (versions=[2, 4]) | ❌ — manual if | ✅ — decorator argument |
| Bounded ranges (ver_range(1, 3)) | ❌ — manual if | ✅ — decorator argument |
| Unversioned routes in same registry | ❌ | ✅ — omit version |
| Deprecation / Sunset headers | ❌ | ✅ — LifecycleMiddleware |
| Version matrix inspection | ❌ | ✅ — check_versions |
| CI snapshot guard | ❌ | ✅ — assert_version_matrix |
| Deterministic URL reversal | ⚠️ — reverse() picks first match | ✅ — api_reverse() requires version |
| Works without DRF | ❌ | ✅ |
| Function-based and class-based views | ✅ | ✅ |
If you already use DRF, keep it. This package doesn't replace DRF's versioning classes — it adds the layer DRF deliberately left to you.
Installation
Install Django API Versioning package from PyPI:
pip install django-api-versioning
Requires Django >= 4.2 and Python >= 3.10. Django REST Framework is optional and auto-detected.
Tested Versions: Django 4.2, 5.0, 5.1, 5.2, 6.0, 6.1 (all verified working)
Quick Start
1. Configure Django settings
# settings.py
INSTALLED_APPS = [
# ... your other apps
"django_api_versioning",
]
API_BASE_PATH = "api/v{version}/"
API_MIN_VERSION = 1
API_MAX_VERSION = 3
API_BASE_PATH must contain the {version} placeholder.
2. Include the generated URL patterns
If your project doesn't have a root URLconf yet:
# settings.py
ROOT_URLCONF = "django_api_versioning.urls"
If it does, include them:
# project/urls.py
from django.urls import include, path
from django_api_versioning.urls import urlpatterns as api_urlpatterns
urlpatterns = [
# ... your other URL patterns
path("", include(api_urlpatterns)),
]
After this one-time setup, you never touch urls.py again. Every @endpoint registers its own routes.
3. Decorate your views
Example for DRF's APIView:
from rest_framework.response import Response
from rest_framework.views import APIView
from django_api_versioning.decorators import endpoint
@endpoint("invoices/<int:invoice_id>/", version=2, view_name="invoice-detail")
class InvoiceDetailView(APIView):
def get(self, request, invoice_id):
return Response({"id": invoice_id, "status": "paid"})
That's it. You now have:
GET /api/v1/invoices/42/ → 404 # endpoint introduced in v2
GET /api/v2/invoices/42/ → {"id": 42, "status": "paid"}
GET /api/v3/invoices/42/ → {"id": 42, "status": "paid"}
GET /api/v4/invoices/42/ → {"id": 42, "status": "paid"}
GET /api/v5/invoices/42/ → {"id": 42, "status": "paid"}
path() converters (int:pk, slug:name) work as expected.
Before and after
Without django-api-versioning — you maintain one path() per
version, and copy unchanged views when a new version ships:
# urls.py — manual, error-prone, hard to keep in sync
from django.urls import path
from . import views
urlpatterns = [
path("api/v1/users/", views.user_list_alpha),
path("api/v2/users/", views.user_list_beta),
path("api/v3/users/", views.user_list_beta), # same view, new path — must remember to add
path("api/v1/orders/", views.order_list),
path("api/v2/orders/", views.order_list), # unchanged — but new path required
path("api/v3/orders/", views.order_list), # unchanged — but new path required
# ... × 150 endpoints
]
With django-api-versioning — one declaration, routing handled. API_MAX_VERSION = 3 in this example.
# apps/users/views.py
from django_api_versioning.decorators import endpoint
@endpoint("users/", version=1)
def user_list_alpha(request):
...
@endpoint("users/", version=2)
def user_list_beta(request):
... # covers v2 and v3 — segment extends to API_MAX_VERSION
# apps/orders/views.py
@endpoint("orders/", version=1)
def order_list(request):
... # covers v1, v2, v3 — segment extends to API_MAX_VERSION
# project/urls.py — written once, never edited when a version is added
from django.urls import include, path
from django_api_versioning.urls import urlpatterns as api_urlpatterns
urlpatterns = [
# ... your existing URLs
path("", include(api_urlpatterns)),
]
The two urls.py blocks above produce the same routing. The difference is what the next version costs:
| Without | With | |
|---|---|---|
| Add a new endpoint in v2 | New path(), new view |
One @endpoint |
| Unchanged endpoint in v3 | New path(), new view function |
Nothing — segment extends |
| Find which view serves v3 | Search three files | python manage.py check_versions |
| Two teams add routes | Merge conflict in urls.py |
Each team edits its own views.py |
| File size after 5 versions × 150 endpoints | ~750 path() entries |
3-line urls.py |
Verify the routing:
python manage.py check_versions
Example output:
api/v1/users/: v1 → user_list_alpha
api/v2/users/: v2 → user_list_beta
api/v3/users/: v3 → user_list_beta # v3 not declared — inherits from v2 segment
api/v1/orders/: v1 → order_list
api/v2/orders/: v2 → order_list # v2 not declared — inherits from v1 segment
api/v3/orders/: v3 → order_list # v3 not declared — inherits from v1 segment
The HTTP responses match the manual-routing version exactly:
GET /api/v1/users/ → (user_list_alpha response)
GET /api/v2/users/ → (user_list_beta response)
GET /api/v3/users/ → (user_list_beta response) # inherited from v2
GET /api/v1/orders/ → (order_list response)
GET /api/v2/orders/ → (order_list response) # inherited from v1
GET /api/v3/orders/ → (order_list response) # inherited from v1
Now suppose v4 changes the users response. Add one declaration:
@endpoint("users/", version=4)
def user_list_gamma(request):
... # covers v4 — v2 segment shrinks to [2, 3] automatically
user_list_beta still serves v2 and v3. user_list_gamma serves v4. No other route changes. In the manual setup, the same change would require editing urls.py, writing user_list_gamma, and re-checking three places to confirm /api/v2/users/ and /api/v3/users/ still point at user_list_beta.
Four syntaxes
Each example below uses a real API evolution pattern. The 404s are not the package's default — they are the declared contract for that specific endpoint.
1. Segment — a new endpoint starting in v3
@endpoint("reports/", version=3)
def report_list(request):
return JsonResponse({"reports": []})
GET /api/v1/reports/ → 404 # route not declared for v1
GET /api/v2/reports/ → 404 # route not declared for v2
GET /api/v3/reports/ → {"reports": []}
GET /api/v4/reports/ → {"reports": []}
GET /api/v5/reports/ → {"reports": []}
The v1 and v2 404s mean "this endpoint did not exist in those versions." Other routes in v1 and v2 are unaffected.
2. Explicit set — v2 and v4 only (rolled back in v3)
@endpoint("ranking/", versions=[2, 4])
def ranking(request):
return JsonResponse({"algorithm": "new"})
GET /api/v1/ranking/ → 404 # not yet shipped
GET /api/v2/ranking/ → {"algorithm": "new"}
GET /api/v3/ranking/ → 404 # rolled back in v3
GET /api/v4/ranking/ → {"algorithm": "new"}
GET /api/v5/ranking/ → 404 # removed in v5
Use this when an endpoint exists in non-contiguous versions — an experiment rolled back and re-shipped, or a feature gated to specific releases.
3. Bounded range — v1 through v3, then removed
from django_api_versioning.decorators import endpoint, ver_range
@endpoint("legacy/", versions=ver_range(1, 3))
def legacy_endpoint(request):
return JsonResponse({"deprecated": True})
GET /api/v1/legacy/ → {"deprecated": true}
GET /api/v2/legacy/ → {"deprecated": true}
GET /api/v3/legacy/ → {"deprecated": true}
GET /api/v4/legacy/ → 404 # removed in v4
GET /api/v5/legacy/ → 404
ver_range(a, b) is inclusive: ver_range(1, 3) == [1, 2, 3].
4. Unversioned — no v{n} prefix
@endpoint("health/")
def health_check(request):
return JsonResponse({"status": "ok"})
GET /health/ → {"status": "ok"}
Unversioned routes are registered at the include prefix, not under API_BASE_PATH. With the setup above (path("", include(...))), the URL is /health/. If you need it under /api/, set API_BASE_PATH = "v{version}/" and include at path("api/", include(...)) — then both versioned and unversioned routes live under /api/.
How segments resolve
Two declarations for the same route:
@endpoint("orders/", version=1)
def order_list_v1(request):
return JsonResponse({"version": 1})
@endpoint("orders/", version=3)
def order_list_v3(request):
return JsonResponse({"version": 3})
GET /api/v1/orders/ → {"version": 1} # order_list_v1
GET /api/v2/orders/ → {"version": 1} # order_list_v1 — v2 is inside the v1 segment
GET /api/v3/orders/ → {"version": 3} # order_list_v3
GET /api/v4/orders/ → {"version": 3} # order_list_v3 — v3 segment extends forward
GET /api/v5/orders/ → {"version": 3} # order_list_v3 — up to API_MAX_VERSION
The v3 declaration shrinks the v1 segment to [1, 2]. Editing order_list_v1 changes v1 and v2. It cannot change v3.
Reliability tools
check_versions — inspect the registered matrix
python manage.py check_versions
Prints every registered URL with the version it serves, and warns about gaps in the declared range.
assert_version_matrix — guard in CI
# tests/test_versions.py
from django_api_versioning.testing import assert_version_matrix
def test_version_matrix():
assert_version_matrix(snapshot=".version_matrix.json")
If the matrix has changed, assert_version_matrix raises VersionMatrixError with a diff:
Version matrix changed. Diff:
api/v5/orders/:
v5: order_list_v3 -> order_list_v5
Run with update=True to accept the new matrix.
A PR that re-routes v5 from order_list_v3 to order_list_v5 now produces a visible line in CI. No silent version drift.
api_reverse — deterministic links
from django_api_versioning.reverse import api_reverse
api_reverse("order-list", version=2) # "/api/v2/orders/"
api_reverse("order-list") # AmbiguousVersionError
Django's reverse() returns whichever URL pattern registered first for a name — adding a new version can silently change that target. api_reverse requires an explicit version.
Deprecation and sunset headers
# settings.py
API_VERSION_LIFECYCLE = {
1: {"status": "retired", "sunset": "2025-01-01"},
2: {"status": "deprecated", "sunset": "2027-06-01"},
3: {"status": "active"},
}
MIDDLEWARE = [
"django_api_versioning.lifecycle.LifecycleMiddleware",
]
Every /api/v2/* response carries:
Deprecation: true
Sunset: Tue, 01 Jun 2027 00:00:00 GMT
Every /api/v1/* request returns:
HTTP/1.1 410 Gone
The middleware extracts the version using this priority:
request.version(set by DRF)resolver_match.kwargs["version"](from URL parameters)- Static URL path (version extracted from the actual request URL path)
This fallback approach ensures Deprecation/Sunset headers and retired version enforcement work correctly even when the version isn't explicitly set in the request or route parameters.
Configuration reference
Django settings
| Setting | Default | Description |
|---|---|---|
API_BASE_PATH |
"api/v{version}/" |
Prefix template. Must contain {version}. |
API_MIN_VERSION |
1 |
Lowest valid version. |
API_MAX_VERSION |
Required | Highest valid version. Required setting with no default. |
ROOT_URLCONF |
None (see note) |
Optional. Set to "django_api_versioning.urls" only if your project has no existing root URLconf. Otherwise use include() in your urls.py. |
API_VERSION_LIFECYCLE |
{} |
Per-version status and sunset. |
API_VERSIONING_CHANNELS |
["path"] |
Reserved. Only path is currently honored. |
API_EFFECTIVE_VERSION_RESOLVER |
None |
Reserved. Not yet wired into routing. |
@endpoint arguments
| Argument | Type | Default | Purpose |
|---|---|---|---|
postfix (positional) |
str |
Required | Resource path, e.g. "users/" or "users/<int:pk>/". Passed as first argument: @endpoint("users/", version=1). |
version |
int | None |
None |
Declares a version segment starting at N and extending through API_MAX_VERSION. |
versions |
list[int] | None |
None |
Explicitly specifies the versions served, e.g. [1, 3, 5] or ver_range(1, 3). |
app_name |
str | None |
None |
Optional path segment for grouping, e.g. "shop" → /api/v1/shop/users/. |
view_name |
str | None |
None |
Django URL name for reverse(). Use stable, unique names. |
Rules:
versionandversionsare mutually exclusive. Providing both raisesVersionRangeError.- If neither is provided, the endpoint is registered as an unversioned route (without the
/api/vN/prefix). version=Nregisters the endpoint for versionsNthroughAPI_MAX_VERSION, withNas itssource_version.versions=[...]registers the endpoint for each listed version, with each version serving as its ownsource_version.- Collision resolution: The declaration with the higher
source_versiontakes precedence. If multiple declarations have the samesource_version, the last registered declaration wins. - If
version < API_MIN_VERSION, a warning is issued and no route is registered.
Helper: ver_range(start, end) returns an inclusive range of versions. For example, ver_range(1, 3) is equivalent to [1, 2, 3].
Known limitations
These are acknowledged gaps in the current release, tracked as issues:
Multi-channel versioning (API_VERSIONING_CHANNELS) is defined in settings but not yet consulted by the routing layer. Only path-based versioning is active.
Per-tenant pinning (API_EFFECTIVE_VERSION_RESOLVER) is validated but not yet used by the registry.
DRF ViewSet classes cannot be passed directly to @endpoint — ViewSet.as_view() requires an actions argument the decorator does not supply. Use APIView or @api_view.
Unversioned routes do not receive the API_BASE_PATH prefix. They are registered at the include prefix (see syntax #4 for the two valid setup combinations).
check_versions gap warnings are under revision.
Each will be addressed in a future release. This README documents only what works today.
Migration from 0.2.0
Breaking change: Version 0.3.0 removes the forward and backward parameters. The segment model is now the default behavior.
| 0.2.0 Behavior | 0.3.0 Equivalent | Migration |
|---|---|---|
@endpoint("x/", version=3) (with API_FORWARD_INHERITANCE=True) |
@endpoint("x/", version=3) |
No change — segment now default |
@endpoint("x/", version=3, forward=False) (pinned to v3 only) |
@endpoint("x/", versions=[3]) |
Use explicit versions list |
@endpoint("x/", version=3, forward=True, backward=False) |
@endpoint("x/", version=3) |
No change — segment now default |
Automated migration:
python manage.py migrate_endpoints --dry-run
python manage.py migrate_endpoints
The migrate_endpoints command rewrites 0.2.0 decorators to 0.3.0 syntax automatically.
Requirements
| Django | Python | Status |
|---|---|---|
| 4.2 (LTS) | 3.10+ | ✅ Supported |
| 5.0 | 3.10+ | ✅ Supported |
| 5.1 | 3.10+ | ✅ Supported |
| 5.2 (LTS, until April 2028) | 3.10+ | ✅ Supported |
| 6.0 | 3.10+ | ✅ Supported |
| 6.1 | 3.10+ | ✅ Supported |
Minimum requirements: Django >= 4.2, Python >= 3.10.
DRF is optional. The package reads request.version, which is what DRF's URLPathVersioning, AcceptHeaderVersioning, NamespaceVersioning, and QueryParameterVersioning set.
Contributing
Setup
python -m venv .venv && source .venv/bin/activate
pip install -r requirements-dev.txt && pip install -e .
pre-commit install
Running tests
pytest
pre-commit run --all-files
Docker
The Dockerfile parameterizes Python via PYTHON_VERSION and delegates
to entrypoint.sh:
docker build -t dapi .
docker run --rm dapi # tests
docker run --rm -e RUN_PRE_COMMIT=true dapi # + lint
docker build --build-arg PYTHON_VERSION=3.15 -t dapi:py315 . # other Python
docker run --rm dapi:py311
Releasing
- Bump
versioninpyproject.tomland updateCHANGELOG.md - Commit, push to
main git tag vX.Y.Z && git push origin vX.Y.Z→ CI publishes to PyPI
Reporting issues
Include your Django and package settings, the @endpoint declarations
involved, expected vs actual URL patterns, and your Python and Django
versions.
License
Released under the MIT License.
Metadata
Release files for django-api-versioning 0.3.4
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| django_api_versioning-0.3.4.tar.gz | 42.2 kB | Without provenance | Details for django_api_versioning-0.3.4.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| django_api_versioning-0.3.4-py3-none-any.whl | Python 3 | none | any | Details for django_api_versioning-0.3.4-py3-none-any.whl |
Total release size: 65.2 kB
Release files / django_api_versioning-0.3.4.tar.gz
| Download URL | django_api_versioning-0.3.4.tar.gz |
|---|---|
| Size | 42.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c4afe2573d5d71660ab3ccf6138363350c497940188beae54bd8ed33e282159a
|
|
BLAKE2b-256 checksum How to use checksums |
4213578a6cdaa3f4d5842aa90a2b40b04f38a5d685d3c27dce2f4a8087b9f73d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.15.2
|
Release files / django_api_versioning-0.3.4-py3-none-any.whl
| Download URL | django_api_versioning-0.3.4-py3-none-any.whl |
|---|---|
| Size | 23.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
43ee030fd56a09d1db2194557a3f9047e825dfb4d58188b8abaee92ad5c12b28
|
|
BLAKE2b-256 checksum How to use checksums |
ebf76a8f5148d5353dde921a0af6b063d76c9662dbfbc345c37039bafb1558fe
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.15.2
|
Security for django-api-versioning 0.3.4
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.