FastAPI Router Versioning
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.jsonfor every active version - Declarative lifecycle: mark a route's introduction, deprecation, and removal with one decorator
- Deprecation headers: opt-in
Deprecation,SunsetandLinkresponse headers on routes in their deprecation window - Latest alias: expose the newest version under a fixed
/latestprefix 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_pathat request time - Route composition support: works across nested routers, WebSockets,
Depends, and OpenAPI Callbacks
Requirements
- Python ≥ 3.10
- FastAPI ≥ 0.120.0 (
0.137.0and0.137.1are excluded: they shipped a routing internals rewrite beforeiter_route_contexts()landed in0.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
License
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4c5bb52e7e56e1c105c94567857edc39be29499d56cae9e7635cde608df90494
|
|
| MD5 |
7bcc3468de738e04b2bd8374c37e0947
|
|
| BLAKE2b-256 |
f1bfd6044c74add522e5a4f232d1d54d13c719407e5a41135d007e58c2d87488
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f6427fab8b2dfb80ebb88f9a71678344aa49dcda84d69332e08a54f1bb190461
|
|
| MD5 |
5715ba2b2a324cb628dbcd35edf0f9eb
|
|
| BLAKE2b-256 |
9126dde5a273087595b6f7ad7ef26df1687eb0fc6696009d79dc2c7486849f73
|