Skip to main content

Django API Versioning

PyPI version PyPI Downloads Build Status Django versions Python versions License: MIT

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.

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.

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:

  1. request.version (set by DRF)
  2. resolver_match.kwargs["version"] (from URL parameters)
  3. 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:

  • version and versions are mutually exclusive. Providing both raises VersionRangeError.
  • If neither is provided, the endpoint is registered as an unversioned route (without the /api/vN/ prefix).
  • version=N registers the endpoint for versions N through API_MAX_VERSION, with N as its source_version.
  • versions=[...] registers the endpoint for each listed version, with each version serving as its own source_version.
  • Collision resolution: The declaration with the higher source_version takes precedence. If multiple declarations have the same source_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

  1. Bump version in pyproject.toml and update CHANGELOG.md
  2. Commit, push to main
  3. 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)

Source distribution for django-api-versioning 0.3.4
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)

Table of built distributions (wheels) for django-api-versioning 0.3.4
File Interpreter ABI Platform Actions
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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page