← Back to the section

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. Not v1.2, not 2026.
  • The version is always preceded by /api. The path /v1/orders without /api is a common mistake.
  • The version goes in the path, not in query parameters. ?version=1 is 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_idid);
  • change a field's type (strint);
  • 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:

  1. Create schemas_v2.py with the new contract.
  2. Add v2_router with the new routes.
  3. v1_router keeps working unchanged — clients migrate gradually.
  4. Mark v1 as deprecated via the Deprecation and Sunset headers.
  5. After the shutdown date, return 410 Gone.

In short

  • The version always goes in the URL path: APIRouter(prefix="/api/v1"). The format is v + an integer.
  • The path starts with /api. /v1/orders without /api is 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.
  • URLs and resources — how to build paths, resource nesting, and HTTP methods in FastAPI.
  • RFC 9457 errors — extending ErrorCode as an example of a non-breaking change.
  • Rate limiting and deprecation — the Sunset header for gracefully shutting down v1.