FastAPI’s routing system is designed for flexibility, but when you need to serve different versions of your API under the same base URL, it can feel like a puzzle. You can achieve this by using URL prefixes for your versioned routes, or by using a header to determine which version of the API the client intends to use.
Let’s dive into the header-based approach first, as it’s often the more robust and cleaner solution for API versioning.
Header-Based Routing
Imagine you have two versions of your API: v1 and v2. You want clients to be able to request either version by specifying a custom header, like X-API-Version.
Here’s how you might set that up in FastAPI:
from fastapi import FastAPI, Header, HTTPException
app = FastAPI()
# In-memory storage for simplicity
# In a real app, this would be a database
items_v1 = {
1: {"name": "Apple", "version": "v1"},
2: {"name": "Banana", "version": "v1"},
}
items_v2 = {
1: {"name": "Orange", "version": "v2"},
2: {"name": "Grape", "version": "v2"},
}
@app.get("/items/{item_id}")
async def read_item(item_id: int, x_api_version: str = Header(None)):
if x_api_version == "v1":
if item_id in items_v1:
return items_v1[item_id]
else:
raise HTTPException(status_code=404, detail="Item not found in v1")
elif x_api_version == "v2":
if item_id in items_v2:
return items_v2[item_id]
else:
raise HTTPException(status_code=404, detail="Item not found in v2")
else:
# Default behavior or error for unknown/missing version
# For this example, we'll default to v1 if no header is provided
if item_id in items_v1:
return items_v1[item_id]
else:
raise HTTPException(status_code=404, detail="Item not found (defaulting to v1)")
@app.post("/items/")
async def create_item(item: dict, x_api_version: str = Header(None)):
new_id = max(items_v1.keys()) + 1 if items_v1 else 1
if x_api_version == "v1":
items_v1[new_id] = {"id": new_id, **item, "version": "v1"}
return {"id": new_id, "version": "v1", **item}
elif x_api_version == "v2":
new_id_v2 = max(items_v2.keys()) + 1 if items_v2 else 1
items_v2[new_id_v2] = {"id": new_id_v2, **item, "version": "v2"}
return {"id": new_id_v2, "version": "v2", **item}
else:
# Default to v1
items_v1[new_id] = {"id": new_id, **item, "version": "v1"}
return {"id": new_id, "version": "v1", **item}
To test this, you’d use curl or a tool like Postman:
# Requesting v1
curl -X GET "http://127.0.0.1:8000/items/1" -H "X-API-Version: v1"
# Requesting v2
curl -X GET "http://127.0.0.1:8000/items/1" -H "X-API-Version: v2"
# Creating an item in v1
curl -X POST "http://127.0.0.1:8000/items/" -H "Content-Type: application/json" -H "X-API-Version: v1" -d '{"name": "Pear"}'
# Creating an item in v2
curl -X POST "http://127.0.0.1:8000/items/" -H "Content-Type: application/json" -H "X-API-Version: v2" -d '{"name": "Mango"}'
This approach keeps your base URL clean (/items/{item_id}) and delegates version selection to the client. It’s great for backward compatibility, allowing older clients to continue using v1 while new clients can opt into v2 by simply changing the X-API-Version header.
URL Prefix-Based Routing
The more traditional method is to prefix your routes with the version number. This makes the version explicit in the URL itself.
Here’s the same functionality, but with URL prefixes:
from fastapi import FastAPI
app = FastAPI()
# In-memory storage
items_v1 = {
1: {"name": "Apple", "version": "v1"},
2: {"name": "Banana", "version": "v1"},
}
items_v2 = {
1: {"name": "Orange", "version": "v2"},
2: {"name": "Grape", "version": "v2"},
}
# Version 1 routes
api_v1 = FastAPI()
@api_v1.get("/items/{item_id}")
async def read_item_v1(item_id: int):
if item_id in items_v1:
return items_v1[item_id]
else:
return {"error": "Item not found in v1"}
@api_v1.post("/items/")
async def create_item_v1(item: dict):
new_id = max(items_v1.keys()) + 1 if items_v1 else 1
items_v1[new_id] = {"id": new_id, **item, "version": "v1"}
return {"id": new_id, "version": "v1", **item}
# Version 2 routes
api_v2 = FastAPI()
@api_v2.get("/items/{item_id}")
async def read_item_v2(item_id: int):
if item_id in items_v2:
return items_v2[item_id]
else:
return {"error": "Item not found in v2"}
@api_v2.post("/items/")
async def create_item_v2(item: dict):
new_id = max(items_v2.keys()) + 1 if items_v2 else 1
items_v2[new_id] = {"id": new_id, **item, "version": "v2"}
return {"id": new_id, "version": "v2", **item}
# Mount the versioned APIs
app.mount("/v1", api_v1)
app.mount("/v2", api_v2)
With this setup, your curl commands would look like this:
# Requesting v1
curl -X GET "http://127.0.0.1:8000/v1/items/1"
# Requesting v2
curl -X GET "http://127.0.0.1:8000/v2/items/1"
# Creating an item in v1
curl -X POST "http://127.0.0.1:8000/v1/items/" -H "Content-Type: application/json" -d '{"name": "Pear"}'
# Creating an item in v2
curl -X POST "http://127.0.0.1:8000/v2/items/" -H "Content-Type: application/json" -d '{"name": "Mango"}'
This approach is very explicit. Clients know exactly which version they are hitting by looking at the URL. It’s also easier to manage as your API grows, because each version can be treated as a distinct sub-application.
Combining and Considerations
You could combine these approaches, perhaps using URL prefixes for major versions (/v1, /v2) and then a header for minor versioning within those (X-API-Minor-Version: 1.1). However, for most use cases, sticking to one primary versioning strategy is cleaner.
The header-based approach is generally preferred when you want to keep your API endpoint URLs stable and avoid breaking existing clients when you introduce new versions. It allows for a smoother transition. The URL prefix approach is more explicit and can be easier to reason about from a URL structure perspective, but it means that clients must update their endpoint URLs to access new versions.
When you choose header-based routing, the Header dependency in FastAPI automatically handles the extraction of the specified header value. If the header is missing or has an unexpected value, you can implement logic to either return an error or default to a specific version, as demonstrated.
The most surprising aspect of header-based routing in practice is how often clients don’t send the version header correctly, leading to unexpected behavior if you don’t have a clear default strategy. Many developers assume clients will always specify the version they want, but in reality, a significant portion might omit it, forcing your API to make a decision. This decision—whether to default to the latest stable version, an older version, or return an error—is critical to maintaining backward compatibility and a smooth user experience for your API consumers.
Once you’ve mastered API versioning, the next challenge is often managing different deployment environments for these versions, such as staging, production, and canary releases.