Imagine: you've shipped an API, clients are using it, and you need to change the response format. If you simply change it, all clients break. Versioning solves this problem: old clients keep working with v1, new ones get v2.
The version in the URL path
The most common approach is to put the version right in the address:
/api/v1/orders
/api/v2/orders
In FastAPI this is done with an APIRouter and a prefix:
from fastapi import FastAPI, APIRouter
v1_router = APIRouter(prefix="/api/v1", redirect_slashes=False)
v2_router = APIRouter(prefix="/api/v2", redirect_slashes=False)
app = FastAPI()
app.include_router(v1_router)
app.include_router(v2_router)
A few rules that help avoid confusion:
- The version is an integer only:
v1,v2,v3. Notv1.2, not2026. - The version is always preceded by
/api. The path/v1/orderswithout/apiis a common mistake. - The version goes in the path, not in query parameters.
?version=1is a bad option: it breaks caching at gateways and complicates routing. - The version does not go in a header (
Accept-Version). Headers are harder to test and debug.
What looks right and what doesn't:
/api/v1/orders ✓
/api/v2/orders ✓
/api/v1.2/orders ✗ — a minor version isn't needed
/api/2026/orders ✗ — a date instead of a version
/orders ✗ — no /api and no version
/v1/orders ✗ — no /api
Breaking and non-breaking changes
Not every API change breaks clients. It's important to tell the two kinds apart:
Non-breaking (safe) — clients keep working without any changes:
- add an optional field to the response;
- add an optional query parameter;
- add a new endpoint;
- add a new value to an enumeration (StrEnum);
- relax validation (increase the maximum string length).
Breaking — require a new version, because the client will stop working:
- remove or rename a field (
order_id→id); - change a field's type (
str→int); - make a parameter required where it was optional;
- remove an endpoint or change its HTTP method;
- change the URL path (
/orders→/sales-orders); - remove a value from an enumeration;
- tighten validation (decrease the maximum length).
The main rule: a new version only for a breaking change. There's no need to create v2 for the sake of a new optional field — add it to the current version.
Forward compatibility: the client ignores the unknown
For non-breaking changes to actually be safe, clients must calmly handle fields they weren't expecting. FastAPI + Pydantic v2 does this by default during deserialization.
Adding an optional field to the response is a safe operation:
from pydantic import BaseModel
class OrderResponse(BaseModel):
order_id: str
status: str
channel: str | None = None # a new field — safe to add in the current version
Old clients that don't know about channel will simply ignore it.
The same rule works for StrEnum. Adding a new value is safe if the client handles unknown values:
from enum import StrEnum
class OrderStatus(StrEnum):
CREATED = "CREATED"
CONFIRMED = "CONFIRMED"
SHIPPED = "SHIPPED"
DELIVERED = "DELIVERED"
# CANCELLED = "CANCELLED" ← adding — safe
The reverse situation: removing a value from a StrEnum is a breaking change, because a client that expected that value will get something unexpected.
Keeping v1 and v2 running at the same time
When a breaking change is unavoidable, v1 isn't turned off right away — both routers run in parallel. Under the hood there's one piece of business logic and different Pydantic schemas:
from fastapi import APIRouter, Depends
from . import schemas_v1, schemas_v2
from .use_cases import OrderUseCases
v1_router = APIRouter(prefix="/api/v1/orders", redirect_slashes=False)
v2_router = APIRouter(prefix="/api/v2/orders", redirect_slashes=False)
@v1_router.get("/{order_id}", response_model=schemas_v1.OrderResponse)
async def get_order_v1(order_id: str, use_cases: OrderUseCases = Depends()):
order = await use_cases.get_order(order_id)
return schemas_v1.OrderResponse.model_validate(order)
@v2_router.get("/{order_id}", response_model=schemas_v2.OrderResponse)
async def get_order_v2(order_id: str, use_cases: OrderUseCases = Depends()):
order = await use_cases.get_order(order_id)
return schemas_v2.OrderResponse.model_validate(order)
The sequence of steps for a breaking change:
- Create
schemas_v2.pywith the new contract. - Add
v2_routerwith the new routes. v1_routerkeeps working unchanged — clients migrate gradually.- Mark
v1as deprecated via theDeprecationandSunsetheaders. - After the shutdown date, return
410 Gone.
In short
- The version always goes in the URL path:
APIRouter(prefix="/api/v1"). The format isv+ an integer. - The path starts with
/api./v1/orderswithout/apiis a mistake. - A new version is created only for a breaking change: removing or renaming a field, changing a type, removing an endpoint.
- Non-breaking changes (an optional field, a new endpoint, a new enum value) are added to the current version without
v2. - Pydantic v2 ignores unknown fields by default during deserialization — that's forward compatibility.
- For a breaking change, v1 isn't turned off right away: both routers run in parallel, backed by one piece of business logic and different schemas.
What to read next
- URLs and resources — how to build paths, resource nesting, and HTTP methods in FastAPI.
- RFC 9457 errors — extending
ErrorCodeas an example of a non-breaking change. - Rate limiting and deprecation — the
Sunsetheader for gracefully shutting down v1.