Skip to main content

FastAPI Router Versioning

Build Status codecov pypi package Supported Python versions License: MIT

Running multiple API versions side by side usually means duplicating routers, hand-rolling prefixes, or branching on request paths. RouterVersioner does it declaratively instead: annotate each route with the version it belongs to, and it generates the URL prefixes, the per-version OpenAPI schema, and the docs, without moving anything else in your app.


Features

  • SemVer and CalVer: version routes with (major, minor) tuples, or with arbitrary sortable strings
  • Per-version docs: isolated Swagger UI, ReDoc, and openapi.json for every active version
  • Declarative lifecycle: mark a route's introduction, deprecation, and removal with one decorator
  • Deprecation headers: opt-in Deprecation, Sunset and Link response headers on routes in their deprecation window
  • Latest alias: expose the newest version under a fixed /latest prefix clients can pin to
  • Self-hosted docs assets: point Swagger UI and ReDoc at your own JS/CSS for air-gapped deployments
  • Reverse proxy and sub-app aware: doc URLs pick up the ASGI root_path at request time
  • Route composition support: works across nested routers, WebSockets, Depends, and OpenAPI Callbacks

Requirements

  • Python ≥ 3.10
  • FastAPI ≥ 0.120.0 (0.137.0 and 0.137.1 are excluded: they shipped a routing internals rewrite before iter_route_contexts() landed in 0.137.2, which this package relies on; supporting that narrow gap would have meant a third compatibility code path instead of the two the package actually needs)

Installation

pip install fastapi-router-versioning
# or
uv add fastapi-router-versioning

Quick start

RouterVersioner is not a request-time dependency; there's no Depends() involved. It's a one-time setup step: you build it, then call .versionize() once, and it reads the routers you gave it and mounts one copy per version. Attach @api_version to every route before calling .versionize(), since that call is what reads the router and wires everything up; routes added to the router afterward are never picked up.

SemVer

from fastapi import APIRouter, FastAPI
from fastapi_router_versioning import RouterVersioner, VersionFormat, api_version

app = FastAPI()
router = APIRouter()


@router.get("/items")
@api_version((1, 0))
def get_items_v1():
    return {"version": "1.0", "items": ["a", "b"]}


@router.get("/items")
@api_version((2, 0))
def get_items_v2():
    return {"version": "2.0", "items": ["a", "b", "c"]}


RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER).versionize()
# Mounts: GET /v1_0/items   GET /v2_0/items
# Docs at: /v1_0/docs, /v2_0/docs

CalVer

from fastapi import APIRouter, FastAPI
from fastapi_router_versioning import RouterVersioner, VersionFormat, api_version

app = FastAPI()
router = APIRouter()


@router.get("/items")
@api_version("2025-01-01")
def get_items():
    return {"release": "2025-01-01"}


RouterVersioner(app=app, routers=router, version_format=VersionFormat.CALVER).versionize()
# Mounts: GET /2025-01-01/items

CalVer tokens can be any string ("2025-01-01", "v3", "stable"...), but they are sorted lexicographically to determine version order. ISO dates and zero-padded numbers ("v01", "v02") sort correctly; unpadded strings like "v1", "v10", "v2" do not, and will place routes under the wrong version.


Route lifecycle

deprecate_in and remove_in describe when a route changes status, without needing a separate route definition per version:

@router.get("/legacy")
@api_version((1, 0), deprecate_in=(2, 0), remove_in=(3, 0))
def legacy_route():
    return {"msg": "I am stable in v1, deprecated in v2, gone in v3."}
Version /legacy present? Marked deprecated?
v1.0 yes no
v2.0 yes yes
v3.0 no n/a

remove_in is optional: deprecate_in alone marks a route deprecated from that version onward with no planned removal, staying available (and deprecated) in every later version.

A route without @api_version isn't excluded; it falls back to default_version ((1, 0) for SemVer, "1" for CalVer, unless overridden).

FastAPI's own deprecated=True (set directly on a route, or inherited from APIRouter(deprecated=True)) is preserved as-is: RouterVersioner copies it into every version unconditionally, on top of whatever deprecate_in computes. deprecate_in only ever turns deprecation on for versions at or after its boundary; it never turns off a deprecated=True that was already set natively. If you set both on the same route, it shows as deprecated in every version, including ones before deprecate_in's boundary. Use one or the other: deprecated=True for an unconditional, version-independent flag; deprecate_in for a per-version lifecycle.

A route with methods=["GET", "POST"] can have just one of its methods taken over by a dedicated route in a later version; the other method keeps being served by the original route. See semver_app.py for a working example.

Routes in their deprecation window can also emit Deprecation, Sunset and Link response headers to clients; see Deprecation headers.


RouterVersioner reference

VersionT, in the types below, is the version type of the format in use: tuple[int, int] for SemVer, str for CalVer.

Parameter Type Default Description
app FastAPI required The FastAPI application instance
routers APIRouter | list[APIRouter] required Router(s) whose routes will be versioned
version_format VersionFormat SEMVER Versioning strategy (SEMVER or CALVER)
prefix_format str | None /v{major}_{minor} / /{version} URL prefix template; supports {major}, {minor}, {version}
semantic_version_format str | None {major}.{minor} / {version} Version label used in Swagger/ReDoc titles
default_version VersionT | None (1, 0) / "1" Fallback version for routes without @api_version
latest_prefix str | None None If set, mounts an alias prefix (e.g. "/latest") pointing to the newest version
include_version_docs bool True Create per-version Swagger UI and ReDoc pages
include_version_openapi_route bool True Create a per-version openapi.json route
include_versions_route bool False Add a GET /versions endpoint listing all active versions
versions_route_path str /versions Path for that endpoint; must start with /
include_versions_dashboard bool False Add an HTML page listing all active versions with links to their docs
versions_dashboard_path str /dashboard Path for that page; must start with /
versions_dashboard_hook Callable[[list[dict], str], str] | None None Replace the built-in dashboard page; receives (version_models, root_path), returns HTML
deprecation_headers bool False Emit Deprecation / Sunset / Link headers on responses of routes in their deprecation window. See Deprecation headers
version_info dict[VersionT, VersionInfo] | None None Per-version VersionInfo(release_date=…, guide=…) feeding those headers, and the /versions / dashboard listings. See VersionInfo reference
sort_routes bool False Sort routes alphabetically by path within each version
callback Callable[[APIRouter, VersionT, str], None] | None None Called once per versioned router, right before it's included in the app
webhook_routers APIRouter | list[APIRouter] | None None Router(s) with webhook definitions annotated via @api_version; each version's schema shows only the webhooks active in it
openapi_hook Callable[[dict, VersionT], dict] | None None Called with (schema, version) for each generated version schema; must return the (possibly modified) schema
swagger_js_url str | None FastAPI CDN Custom URL for the Swagger UI JS bundle
swagger_css_url str | None FastAPI CDN Custom URL for the Swagger UI CSS
swagger_favicon_url str | None FastAPI favicon Custom URL for the Swagger UI favicon
redoc_js_url str | None FastAPI CDN Custom URL for the ReDoc JS bundle
redoc_favicon_url str | None FastAPI favicon Custom URL for the ReDoc favicon
redoc_with_google_fonts bool True Set False to stop ReDoc from loading Google Fonts

.versionize() returns the list of versions it activated. It can only be called once per instance; a second call raises RuntimeError, since it mutates the live FastAPI app in a way that can't be undone.


@api_version reference

@api_version(version, *, deprecate_in=None, remove_in=None)
Parameter Type Required Description
version tuple[int, int] | str yes Version in which the route first appears
deprecate_in same type | None no Version from which the route is flagged deprecated in the docs
remove_in same type | None no Version from which the route stops being mounted

version, deprecate_in, and remove_in must all match the version_format in use on the RouterVersioner that will process the route (tuple[int, int] for SemVer, str for CalVer). Deprecation headers are configured on RouterVersioner, not here; see Deprecation headers.


VersionInfo reference

VersionInfo(release_date=None, guide=None)

Calendar metadata about one version, passed to RouterVersioner as version_info={version: VersionInfo(...)}. It never affects routing. Both fields feed the deprecation headers, and only when deprecation_headers=True; see Deprecation headers. guide is also listed for the version on GET /versions and the dashboard, which don't need that flag.

Field Type Default Description
release_date date | datetime | None None The date this version goes live. Becomes the Deprecation header on routes whose deprecate_in is this version, and the Sunset header on routes whose remove_in is this version
guide str | None None URL of this version's upgrade guide. Becomes the Link: <guide>; rel="deprecation" header on routes deprecated at this version, and is listed as guide_url for the version on GET /versions and the dashboard

VersionInfo is frozen, and both fields are optional.

release_date accepts a datetime as well as a date, and the two are read differently: a date is pinned to midnight UTC, an aware datetime keeps its own offset, and a naive datetime is read as UTC. Both headers carry a point in time, so passing a naive datetime from a non-UTC local clock shifts the value clients see.


Advanced options

Deprecation headers

By default the lifecycle is docs-only: a deprecated route is flagged in its per-version OpenAPI, but a client calling it gets no signal. Set deprecation_headers=True and RouterVersioner adds the Deprecation, Sunset and Link headers to every response of a route in its deprecation window; version_info (one VersionInfo per version) supplies the dates and guide URLs. Header values are computed once, at versionize() time. The only per-request work is prefixing the successor-version link with the request's root_path, exactly as the built-in /docs and /openapi.json routes do.

from datetime import date
from fastapi_router_versioning import RouterVersioner, VersionInfo

RouterVersioner(
    app=app,
    routers=router,
    deprecation_headers=True,
    version_info={
        (1, 0): VersionInfo(release_date=date(2024, 1, 15)),
        (2, 0): VersionInfo(
            release_date=date(2025, 3, 1),
            guide="https://api.example.com/docs/upgrade/v2",
        ),
        (3, 0): VersionInfo(release_date=date(2026, 1, 1)),
    },
).versionize()
Header Source Emitted when
Deprecation: @<unix-seconds> (RFC 9745) version_info[deprecate_in].release_date that version has a date
Sunset: <HTTP-date> (RFC 8594) version_info[remove_in].release_date remove_in is set and that version has a date
Link: …; rel="successor-version" (RFC 5829) the next mounted version still serving the same (path, method), root_path-prefixed such a version exists
Link: …; rel="deprecation" (RFC 9745) version_info[deprecate_in].guide that version has a guide URL

Both VersionInfo fields are optional; a version missing from the map, or a field left None, just yields nothing for that part, with no warning. With deprecation_headers=True and no version_info at all, only the successor-version link is emitted (it needs no config). versionize() raises ValueError if a route's remove_in date precedes its deprecate_in date (when both are dated): RFC 9745 forbids a Sunset earlier than the Deprecation.

This adds nothing to @api_version: the lifecycle stays on the route, the dates stay on the versioner. Deprecation and Sunset are only set if the response doesn't already carry them: a route that sets its own keeps it. The Link field is sent alongside any the route already emits (RFC 8288: multiple Link fields combine). See deprecation_headers_app.py.

Latest alias

RouterVersioner(
    app=app,
    routers=router,
    version_format=VersionFormat.SEMVER,
    latest_prefix="/latest",
).versionize()
# /latest/... now points at whichever version is currently highest

/latest gets its own docs pages like any other version, but it isn't listed in GET /versions or the dashboard: those list versions, and /latest is a pointer to one of them.

Version discovery endpoint

RouterVersioner(
    app=app,
    routers=router,
    version_format=VersionFormat.SEMVER,
    include_versions_route=True,
).versionize()
GET /versions
{
  "versions": [
    {
      "version": "1.0",
      "openapi_url": "/v1_0/openapi.json",
      "swagger_url": "/v1_0/docs",
      "redoc_url": "/v1_0/redoc"
    }
  ]
}

Pass versions_route_path="/api-versions" (any path starting with /) to mount the endpoint somewhere other than /versions.

A version whose VersionInfo sets guide also carries a "guide_url" in its entry, sent as given (an external URL, with no root_path prefix).

If several RouterVersioner instances share one app and all set include_versions_route=True, /versions is mounted once and lists every instance's versions together, instead of the first instance shadowing the rest (see Multiple routers). That single endpoint is mounted by the first of those instances, which also fixes its path: a different versions_route_path passed by a later instance has no effect.

Set include_versions_dashboard=True for an HTML counterpart: a page (at /dashboard by default, moved with versions_dashboard_path) headed by the app's title and version and listing the same versions with links to each one's Swagger, ReDoc, openapi.json, and its guide when version_info gives one. It aggregates across instances and follows the same first-instance-wins rule, works whether or not include_versions_route is also on, and is kept out of the OpenAPI schema. The built-in page is plain; pass versions_dashboard_hook(version_models, root_path) -> str to render your own instead (it isn't handed the app metadata, so read app.title / app.version off your own app reference if you want them).

Custom URL format

prefix_format and semantic_version_format control how a version renders in URLs and in doc titles, independently of how it's expressed in @api_version. A common use is dropping the minor number from the URL while still tracking it internally:

RouterVersioner(
    app=app,
    routers=router,
    version_format=VersionFormat.SEMVER,
    prefix_format="/v{major}",
    semantic_version_format="{major}",
    latest_prefix="/latest",
).versionize()
# Mounts: GET /v1/items   GET /v2/items   GET /latest/items
# Swagger titles read "v1", "v2" instead of "v1.0", "v2.0"

Routes are still decorated with (major, minor) tuples; only their URL and label change.

OpenAPI schema hook

openapi_hook runs inside the per-version schema generation pipeline, so unlike patching app.openapi yourself, it always receives the already-filtered schema for that specific version:

def my_openapi_hook(schema: dict, version: tuple[int, int]) -> dict:
    schema["info"]["x-logo"] = {"url": "https://example.com/logo.png"}

    if version == (1, 0):
        schema["info"]["description"] += "\n\n**DEPRECATED:** Use v2."

    return schema

RouterVersioner(
    app=app,
    routers=router,
    version_format=VersionFormat.SEMVER,
    openapi_hook=my_openapi_hook,
).versionize()

Custom validation error status code

Changing FastAPI's default 422 for request validation errors is not something RouterVersioner does itself: it's a separate, general-purpose concern, handled by the fastapi-validation-override package. openapi_hook is the integration point: it lets you re-apply the same patch to every per-version schema that RouterVersioner generates, so all of them, root schema included, stay consistent.

uv add fastapi-validation-override
from fastapi_validation_override import override_validation_error, patch_422_responses

# 1. Registers the runtime handler and patches the app's own root /openapi.json.
override_validation_error(app, status_code=400)

# 2. Re-applies the same patch to each version's own schema.
def versioning_openapi_hook(schema: dict, version: tuple[int, int]) -> dict:
    return patch_422_responses(schema, "400")

RouterVersioner(
    app=app,
    routers=router,
    version_format=VersionFormat.SEMVER,
    openapi_hook=versioning_openapi_hook,
).versionize()
# Validation failures now return 400, both at runtime and in every schema
# (root, and every /vX_Y/openapi.json)

See examples/validation_override_integration_app.py.

OpenAPI Callbacks and Webhooks

Route-level Callbacks need no special handling: a callbacks=[...] argument on a route is carried over to every versioned copy of it automatically:

callback_router = APIRouter()

@callback_router.post("{$url}")
def on_event(body: dict) -> None: ...

@router.post("/items", callbacks=callback_router.routes)
@api_version((1, 0))
def create_item() -> dict: ...

Webhooks (app.webhooks) are visible in every version's schema by default. Pass webhook_routers to version them the same way as regular routes, with @api_version on each definition:

webhook_router = APIRouter()

@webhook_router.post("/order-created")
@api_version((1, 0))
def webhook_order_v1(body: OrderV1) -> None: ...

@webhook_router.post("/order-created")
@api_version((2, 0))       # same path + method as v1: replaces it, doesn't add a second entry
def webhook_order_v2(body: OrderV2) -> None: ...

@webhook_router.post("/payment-failed")
@api_version((1, 0), remove_in=(2, 0))
def webhook_payment_v1(body: dict) -> None: ...

RouterVersioner(
    app=app,
    routers=router,
    webhook_routers=webhook_router,
    version_format=VersionFormat.SEMVER,
).versionize()
# /v1_0/openapi.json lists: order-created (v1 payload), payment-failed
# /v2_0/openapi.json lists: order-created (v2 payload)   payment-failed is gone

A webhook version only becomes visible once a route version reaches that same prefix, since both follow the same remove_in lifecycle.

Multiple routers

RouterVersioner(
    app=app,
    routers=[users_router, products_router],
    version_format=VersionFormat.SEMVER,
).versionize()

Both routers are versioned together, sharing the same prefix tree, so this is the way to split a versioned API across modules without creating a second RouterVersioner.

Sharing one app across several RouterVersioner instances only makes sense for one reason: mixing version_format values, SemVer for one group of routes and CalVer for another, on the same app. Splitting modules that share a version_format doesn't need a second instance; pass them all to one RouterVersioner via routers=[...] instead (see multi_router_app.py). If you do share an app across instances, one rule is enforced for you: every instance needs its own prefix_format/latest_prefix. Two instances that resolve to the same prefix would otherwise overwrite each other's docs/openapi routes at the same path; this raises RuntimeError. /versions (see Version discovery endpoint) doesn't need this coordination: every instance's contribution is aggregated into the same endpoint automatically.

For modules that genuinely don't need to coordinate at all, mount them as separate FastAPI sub-applications instead (see Reverse proxy and sub-application mounting).

Self-hosted docs assets

Swagger UI and ReDoc load their JS/CSS from FastAPI's CDN by default. Point them at your own copies for air-gapped or restricted-network deployments:

RouterVersioner(
    app=app,
    routers=router,
    version_format=VersionFormat.SEMVER,
    swagger_js_url="/static/swagger-ui-bundle.js",
    swagger_css_url="/static/swagger-ui.css",
    swagger_favicon_url="/static/favicon.png",
    redoc_js_url="/static/redoc.standalone.js",
    redoc_favicon_url="/static/favicon.png",
    redoc_with_google_fonts=False,
).versionize()

examples/download_static_assets.py downloads the required files in one step; examples/self_hosted_docs_app.py wires them into a full app.

Reverse proxy and sub-application mounting

The ASGI root_path FastAPI sets when an app runs behind a proxy or is mounted with app.mount() is picked up automatically in every per-version doc URL:

parent = FastAPI()
parent.mount("/api", app)  # root_path="/api" is injected per request
# /api/v1_0/docs correctly points at /api/v1_0/openapi.json

A mounted sub-application is a separate FastAPI() instance with its own app.state, so a RouterVersioner attached to it is entirely independent from one attached to the parent, or to another sub-application, no prefix coordination needed. See examples/mounted_subapps_app.py.

Callback hook

callback runs once per versioned router, right before RouterVersioner includes it in the app, handy for logging every mount point or wiring metrics:

def on_version_created(router: APIRouter, version, prefix: str) -> None:
    print(f"Registered version {version} at {prefix}")

RouterVersioner(
    app=app,
    routers=router,
    version_format=VersionFormat.SEMVER,
    callback=on_version_created,
).versionize()

If versionize() raises (e.g. the callback itself throws), don't catch the exception and retry: some versions may already be mounted, and this package makes no attempt to undo that. versionize() normally runs at startup, so the app simply fails to start — fix the underlying issue and start it again.


Examples

File What it shows
semver_app.py Full SemVer lifecycle: introduce, deprecate, remove, permanent deprecation, multi-method route with partial takeover
calver_app.py Same lifecycle, CalVer date strings instead
semver_major_only_app.py Major-only URLs (/v1, /v2) via prefix_format
deprecation_headers_app.py Deprecation, Sunset and Link response headers via deprecation_headers and version_info
webhook_versioning_app.py Per-version webhook definitions via webhook_routers
multi_router_app.py Several routers versioned together under one instance
self_hosted_docs_app.py Swagger UI and ReDoc served from local static assets
openapi_hook_app.py Per-version OpenAPI schema edits via openapi_hook
versions_dashboard_app.py GET /versions JSON and the GET /dashboard HTML page side by side
versions_dashboard_hook_app.py Custom dashboard via versions_dashboard_hook: Jinja template with a separate stylesheet file
mounted_subapps_app.py Independently versioned modules as separate app.mount() sub-applications
validation_override_integration_app.py Custom validation error status code via fastapi-validation-override and openapi_hook

Release Notes

RELEASE_NOTES


License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

fastapi_router_versioning-1.1.0.tar.gz (144.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fastapi_router_versioning-1.1.0-py3-none-any.whl (32.7 kB view details)

Uploaded Python 3

File details

Details for the file fastapi_router_versioning-1.1.0.tar.gz.

File metadata

  • Download URL: fastapi_router_versioning-1.1.0.tar.gz
  • Upload date:
  • Size: 144.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.18 {"installer":{"name":"uv","version":"0.11.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for fastapi_router_versioning-1.1.0.tar.gz
Algorithm Hash digest
SHA256 4c5bb52e7e56e1c105c94567857edc39be29499d56cae9e7635cde608df90494
MD5 7bcc3468de738e04b2bd8374c37e0947
BLAKE2b-256 f1bfd6044c74add522e5a4f232d1d54d13c719407e5a41135d007e58c2d87488

See more details on using hashes here.

File details

Details for the file fastapi_router_versioning-1.1.0-py3-none-any.whl.

File metadata

  • Download URL: fastapi_router_versioning-1.1.0-py3-none-any.whl
  • Upload date:
  • Size: 32.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.18 {"installer":{"name":"uv","version":"0.11.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for fastapi_router_versioning-1.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f6427fab8b2dfb80ebb88f9a71678344aa49dcda84d69332e08a54f1bb190461
MD5 5715ba2b2a324cb628dbcd35edf0f9eb
BLAKE2b-256 9126dde5a273087595b6f7ad7ef26df1687eb0fc6696009d79dc2c7486849f73

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

1.1.0 This release

2 files

1.0.3

2 files

1.0.2

2 files

1.0.1

2 files

1.0.0

2 files

0.3.0

2 files

0.2.7

2 files

0.2.6

2 files

0.2.5

2 files

0.2.4

2 files

0.2.3

2 files

0.2.2

2 files

0.2.1

2 files

0.2.0

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

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