Versioning APIs with FastAPI Routers
Key takeaways:
- Give each version its own
APIRouterwith a version prefix, and include both into one app. - Share only genuinely stable fields through a base model; each version owns its response model.
deprecated=Trueon a router marks every operation beneath it in the generated OpenAPI document.- Never edit a shipped version's model to serve the new version — that is the breakage versioning prevents.
- Instrument per-version traffic so the delete date comes from data, not a calendar.
This guide builds on Modular Router Organization, which covers composing domain routers; here the composition axis is the API version.
The Problem This Solves
You shipped /users/{id} returning a single name string. Two years later the product needs given and family names separately, plus a locale. Mobile clients from three releases ago still call the old shape and cannot be forced to upgrade. You need both shapes served correctly, from one deployment, with the old one clearly labelled as on its way out — and you need the new work not to break the old shape by accident.
The accidental-breakage part is the hard bit. Most FastAPI versioning failures are not routing failures; they are model failures. Someone adds a field to the model that both versions return, or tightens a constraint, and v1's contract changes without a single line of v1 code being touched.
Why It Happens
include_router copies each route into the parent's routing table with the prefix applied, and it records the response model attached to that route at declaration time. There is no linkage back to the router afterwards. So the version boundary in FastAPI is entirely a code organisation boundary — the framework will happily let /v1 and /v2 point at the same handler, the same model, and the same database query.
That means the framework gives you the routing for free and gives you nothing at all for the contract. The contract discipline has to come from how you structure the models: a version's response model must be a leaf that nothing else edits.
Inheritance is the tool that fits. A UserCore holding only the fields that are genuinely identical across versions, with UserV1 and UserV2 inheriting from it, gives you one place to fix an email-field typo and two places that are free to diverge. Adding a field to UserV2 cannot touch UserV1, because inheritance flows downward only.
The Fix
One module per version, each exporting a router, both included in the application factory. The v1 router carries deprecated=True; both carry explicit operation_id values so generated clients get stable method names.
"""Run /v1 and /v2 side by side from shared models, and mark v1 deprecated in OpenAPI."""
from fastapi import APIRouter, FastAPI
from pydantic import BaseModel, Field
class UserCore(BaseModel):
"""Fields that are identical in every version. Changing this changes both."""
id: int
email: str
class UserV1(UserCore):
"""v1 shipped a single `name` string."""
name: str
class UserV2(UserCore):
"""v2 split the name and added a field. v1 clients never see it."""
given_name: str
family_name: str
locale: str = Field(default="en-GB")
RECORDS = {1: {"id": 1, "email": "ada@example.com", "given": "Ada", "family": "Lovelace"}}
v1 = APIRouter(prefix="/v1", tags=["users v1"], deprecated=True)
@v1.get("/users/{user_id}", response_model=UserV1, operation_id="get_user_v1")
async def get_user_v1(user_id: int) -> UserV1:
row = RECORDS[user_id]
return UserV1(id=row["id"], email=row["email"], name=f"{row['given']} {row['family']}")
v2 = APIRouter(prefix="/v2", tags=["users v2"])
@v2.get("/users/{user_id}", response_model=UserV2, operation_id="get_user_v2")
async def get_user_v2(user_id: int) -> UserV2:
row = RECORDS[user_id]
return UserV2(
id=row["id"], email=row["email"], given_name=row["given"], family_name=row["family"]
)
app = FastAPI(title="Versioned API")
app.include_router(v1)
app.include_router(v2)
Note that the storage shape (RECORDS, with given and family) belongs to neither version. Both versions adapt from it. That is the second half of the discipline: if v1's handler reads the database row directly into a response model, then a migration that renames a column becomes a v1 API change.
To prove the deprecation flag actually reaches the document rather than trusting that it does, this example reads its own generated OpenAPI back:
@app.get("/_meta/versions")
async def versions() -> dict:
"""Read the REAL generated OpenAPI document back and report each operation's state."""
spec = app.openapi()
out = []
for path, methods in sorted(spec["paths"].items()):
for method, op in sorted(methods.items()):
if path.startswith("/_meta"):
continue
out.append(
{
"path": path,
"method": method.upper(),
"operationId": op["operationId"],
"deprecated": op.get("deprecated", False),
"responseSchema": op["responses"]["200"]["content"]["application/json"][
"schema"
]["$ref"],
}
)
return {"operations": out}
Both versions answering, and the generated document describing them, in one real run of this app:
$ GET /v1/users/1
200 OK
{
"id": 1,
"email": "ada@example.com",
"name": "Ada Lovelace"
}
$ GET /v2/users/1
200 OK
{
"id": 1,
"email": "ada@example.com",
"given_name": "Ada",
"family_name": "Lovelace",
"locale": "en-GB"
}
$ GET /_meta/versions
200 OK
{
"operations": [
{
"path": "/v1/users/{user_id}",
"method": "GET",
"operationId": "get_user_v1",
"deprecated": true,
"responseSchema": "#/components/schemas/UserV1"
},
{
"path": "/v2/users/{user_id}",
"method": "GET",
"operationId": "get_user_v2",
"deprecated": false,
"responseSchema": "#/components/schemas/UserV2"
}
]
}
Three things in that transcript are worth pausing on. locale appears in the v2 body with its default of en-GB and does not appear in the v1 body at all — the response model, not the handler, decides what ships. The v1 operation carries "deprecated": true while v2 does not, from a single flag on the router. And the two operations reference distinct schemas, UserV1 and UserV2, so a generated client produces two distinct types rather than one union that fits neither.
Where the version boundary should stop
The routers and the response models are versioned. Almost nothing else should be.
Duplicating the service layer per version is the most expensive mistake available here, because it doubles the surface every bug fix has to be applied to, and the two copies drift within months. One UserService serves both versions; each version's handler adapts its output into that version's model. The adapter is usually three or four lines, and those three or four lines are the entire cost of keeping the old contract alive.
Dependencies follow the same rule. Authentication, rate limiting, tracing and database sessions are cross-cutting and belong on the app or on a shared parent router, not duplicated per version — see Dependency Injection Strategies for where to attach them. The exception is a dependency whose behaviour is part of the contract: if v1 promised a permissive pagination limit and v2 tightened it, that dependency is versioned data, and it goes with the version.
The practical test is to ask what a change to the shared thing would do to v1. If the answer is "nothing visible", share it. If the answer is "v1's responses change", it is not shared — it is v2's, and v1 needs its own copy frozen at the shape it shipped with.
Verification
The regression you are guarding against is a v2 change leaking into v1, and it is cheap to lock down. Snapshot v1's schema and assert on it:
def test_v1_contract_is_frozen():
schema = app.openapi()["components"]["schemas"]["UserV1"]
assert sorted(schema["properties"]) == ["email", "id", "name"]
assert sorted(schema["required"]) == ["email", "id", "name"]
def test_v1_response_has_no_v2_fields():
body = client.get("/v1/users/1").json()
assert "locale" not in body and "given_name" not in body
The first test fails the moment someone adds a field to UserCore intending to serve v2. That is the alarm you want, and it is why the shared base should stay small: every field you put in it is a field two versions have to agree on forever.
For deployed systems, add a per-version request counter — a label on your metrics middleware is enough, and Prometheus metrics for FastAPI covers the wiring. Deprecation without a traffic graph is guesswork.
Trade-offs and When Not To
URL-prefix versioning is not free. Every route exists twice in the routing table, every version needs its own tests, and the /docs page grows. In exchange you get a version that is visible in access logs, in a curl command, in an error report from a customer, and in the OpenAPI document — which is what makes it the default choice.
Do not version at all if you can avoid it. Additive changes — a new optional response field, a new optional query parameter — are backward compatible and need no version bump. Reserve versions for changes that genuinely break a client: removing a field, renaming one, tightening a validation rule, or changing a status code.
Do not version per endpoint. A /users/v2 alongside /orders/v1 produces a matrix that no client can reason about. Version the whole surface at once, even when only one endpoint changed, so a consumer can say "we are on v2" and mean something.
Consider a sub-application instead when a version needs its own middleware stack or its own lifespan — a v3 written against a different auth scheme, say. APIRouter Prefix vs Sub-Application Mounting sets out that decision in full; the short version is that a mounted app gets isolation and loses the single unified schema.
Header-based versioning is defensible when URLs must be permanent, for example when they are used as resource identifiers elsewhere. Implement it as a dependency that reads Accept or a custom header and dispatches, and accept that you have given up on the version being visible in your logs and dashboards unless you add it there yourself.
FAQ
Should I version with a URL prefix or a header? A URL prefix is the pragmatic default because it is visible in logs, curl commands, dashboards and OpenAPI without any custom negotiation code. Header versioning keeps URLs stable but hides the version from every tool you debug with, and it needs a dependency to read and dispatch on the header.
Can two versions share the same Pydantic model? They can share a base model of genuinely stable fields, but each version should own its own response model that inherits from it. The moment a shared model is edited to satisfy v2, v1's contract changes silently, which is exactly the failure versioning exists to prevent.
How do I mark a version deprecated in the OpenAPI document?
Pass deprecated=True to the APIRouter, or to individual path operations. FastAPI writes deprecated: true onto every affected operation, Swagger UI strikes them through, and client generators emit deprecation annotations that surface in consumers' IDEs.
Does running two versions in one app slow anything down? Route matching is a linear scan over compiled path regexes, so a second version adds negligible per-request cost. The real cost is maintenance: every bug fix has to be assessed against both versions, which is the argument for deleting old versions rather than keeping them cheap.
When can I actually delete v1? When traffic to it reaches zero and stays there. Instrument per-version request counts before you announce the deprecation, since the decision to delete should be made from a graph rather than from a date in a changelog.
Related Reading
- Up to the topic: Modular Router Organization explains the router composition this guide versions along.
- Where the versioned routers are assembled: Application Factory Patterns.
- The isolation alternative: APIRouter Prefix vs Sub-Application Mounting.
- Making versions legible in the generated docs and clients: Router Tags and OpenAPI Grouping.
- Laying out the version packages on disk: Structuring Large FastAPI Projects for Scale.